diff --git a/apps/server/src/modules/schedules/store.ts b/apps/server/src/modules/schedules/store.ts index 5a603fdf..c4d9f114 100644 --- a/apps/server/src/modules/schedules/store.ts +++ b/apps/server/src/modules/schedules/store.ts @@ -2,6 +2,8 @@ import { randomUUID } from "node:crypto"; import { AgentCheckConfig, type AgentCheckCreate, + type AgentCheckRunStats, + type AgentRunStatus, type ScheduleBoard, type ScheduledJob, type ScheduledJobRun, @@ -165,6 +167,9 @@ export async function board(db: Kysely): Promise { .orderBy("id") .execute(); const items = rows.map(jobView); + const checks = items.filter((j) => j.kind === "agent_check").map((j) => j.id); + const stats = await agentCheckRunStats(db, checks, new Date()); + for (const j of items) if (j.kind === "agent_check") j.agentRuns = stats.get(j.id) ?? emptyRunStats(); return { items, stats: { @@ -177,6 +182,95 @@ export async function board(db: Kysely): Promise { }; } +/** 巡检费用 / 模型汇总取最近多少次会话 */ +export const AGENT_CHECK_STATS_RUNS = 100; +const RECENT_RUNS = 3; +const WEEK_MS = 7 * 86_400_000; + +const emptyRunStats = (): AgentCheckRunStats => ({ + count: 0, + lastCostUsd: null, + avgCostUsd: null, + weekCount: 0, + weekCostUsd: 0, + lastProvider: null, + lastModel: null, + models: [], + recent: [], +}); + +/** 各巡检最近 100 次会话(created_by = schedule:,不含子代理)的费用与模型汇总 */ +export async function agentCheckRunStats( + db: Kysely, + jobIds: string[], + now: Date, +): Promise> { + const out = new Map(); + if (!jobIds.length) return out; + const rows = await sql<{ + job_id: string; + id: string; + status: AgentRunStatus; + goal: string; + summary: string | null; + provider: string | null; + model: string | null; + cost: number | null; + created_at: Date; + finished_at: Date | null; + }>` + select j.id as job_id, r.* + from unnest(${jobIds}::text[]) as j(id) + cross join lateral ( + select id, status, goal, summary, provider, model, (usage->>'costUsd')::float8 as cost, created_at, finished_at + from agent_run + where created_by = 'schedule:' || j.id and parent_run_id is null + order by created_at desc + limit ${AGENT_CHECK_STATS_RUNS} + ) r`.execute(db); + const weekFrom = now.getTime() - WEEK_MS; + const costSum = new Map(); + for (const r of rows.rows) { + const st = out.get(r.job_id) ?? emptyRunStats(); + out.set(r.job_id, st); + const cost = typeof r.cost === "number" && Number.isFinite(r.cost) ? r.cost : null; + if (st.count === 0) st.lastCostUsd = cost; + st.count++; + const inWeek = new Date(r.created_at).getTime() >= weekFrom; + if (inWeek) st.weekCount++; + if (cost !== null) { + const c = costSum.get(r.job_id) ?? { sum: 0, n: 0 }; + costSum.set(r.job_id, { sum: c.sum + cost, n: c.n + 1 }); + if (inWeek) st.weekCostUsd += cost; + } + if (r.model) { + if (!st.lastModel) { + st.lastModel = r.model; + st.lastProvider = r.provider; + } + const m = st.models.find((x) => x.model === r.model && x.provider === r.provider); + if (m) m.count++; + else st.models.push({ provider: r.provider, model: r.model, count: 1 }); + } + if (st.recent.length < RECENT_RUNS) + st.recent.push({ + id: r.id, + status: r.status, + goal: r.goal, + summary: r.summary, + costUsd: cost, + createdAt: new Date(r.created_at).toISOString(), + finishedAt: r.finished_at ? new Date(r.finished_at).toISOString() : null, + }); + } + for (const [id, st] of out) { + const c = costSum.get(id); + st.avgCostUsd = c?.n ? c.sum / c.n : null; + st.models.sort((a, b) => b.count - a.count); + } + return out; +} + export async function updateJob( db: Kysely, id: string, diff --git a/apps/server/test/schedules/schedules.test.ts b/apps/server/test/schedules/schedules.test.ts index cad167fa..b49f4a0a 100644 --- a/apps/server/test/schedules/schedules.test.ts +++ b/apps/server/test/schedules/schedules.test.ts @@ -297,6 +297,27 @@ describe.skipIf(!hasPg)("定时任务注册表(真实 PG)", () => { expect(d).toMatchObject({ kind: "other", severity: "info", summary: "一切正常" }); expect(await sched.harvest()).toBe(0); + // INT-3:看板给巡检带最近会话的费用与模型汇总 + await h.db + .updateTable("agent_run") + .set({ provider: "anthropic", model: "claude-x", usage: JSON.stringify({ costUsd: 0.25 }) }) + .where("id", "=", ar.id) + .execute(); + const listed = (await api("GET", "/schedules")).body.data.items.find((j: { id: string }) => j.id === id); + expect(listed.agentRuns).toMatchObject({ + count: 1, + lastCostUsd: 0.25, + avgCostUsd: 0.25, + weekCount: 1, + weekCostUsd: 0.25, + lastProvider: "anthropic", + lastModel: "claude-x", + models: [{ provider: "anthropic", model: "claude-x", count: 1 }], + recent: [{ id: ar.id, status: "succeeded", summary: "一切正常", costUsd: 0.25 }], + }); + const plain = (await api("GET", "/schedules")).body.data.items.find((j: { id: string }) => j.id === "test.keep"); + expect(plain.agentRuns).toBeUndefined(); + // 自定义可改名、可删;内置不可删 expect((await api("PATCH", `/schedules/${id}`, { name: "夜间巡检" })).body.data.name).toBe("夜间巡检"); expect((await api("DELETE", "/schedules/test.keep")).status).toBe(409); diff --git a/apps/web/app/api/agent.ts b/apps/web/app/api/agent.ts index 07dde5b4..ed991cea 100644 --- a/apps/web/app/api/agent.ts +++ b/apps/web/app/api/agent.ts @@ -413,31 +413,3 @@ export function useRunInvocationAudit(runId: string, enabled: boolean, version = }, }); } - -/** - * 巡检触发的最近会话(trigger=schedule,createdBy = schedule:<定时任务 id>): - * 计划页的巡检卡片、首页计划带按定时任务分组取费用、模型与最近结果。一次取最近 100 条。 - */ -export function useScheduleAgentRuns() { - return useQuery({ - queryKey: [...agentKeys.runs(undefined, "schedule"), "plan"], - queryFn: ({ signal }) => - apiParsed(RunPage, AGENT_BASE, { params: cleanParams({ trigger: "schedule", limit: 100 }), signal }), - select: (page) => page.items, - staleTime: 30 * TTL_SECOND, - refetchInterval: 60 * TTL_SECOND, - }); -} - -/** 按定时任务 id 分组(新 → 旧,保持接口顺序) */ -export function groupRunsBySchedule(runs: AgentRun[]): Map { - const out = new Map(); - for (const r of runs) { - const id = r.createdBy?.startsWith("schedule:") ? r.createdBy.slice("schedule:".length) : null; - if (!id) continue; - const list = out.get(id); - if (list) list.push(r); - else out.set(id, [r]); - } - return out; -} diff --git a/apps/web/app/components/schedules/agent-check-card.tsx b/apps/web/app/components/schedules/agent-check-card.tsx index 27eb4c8e..185f0e61 100644 --- a/apps/web/app/components/schedules/agent-check-card.tsx +++ b/apps/web/app/components/schedules/agent-check-card.tsx @@ -1,10 +1,4 @@ -import { - type AgentCheckConfig, - type AgentRun, - CAPABILITY_CATALOG, - type KnownCapabilityId, - type ScheduledJob, -} from "@umt/contracts"; +import { type AgentCheckConfig, CAPABILITY_CATALOG, type KnownCapabilityId, type ScheduledJob } from "@umt/contracts"; import { History, LoaderCircle, Pencil, Play, RotateCcw, SkipForward, Trash2 } from "lucide-react"; import { type ReactNode, useEffect, useRef } from "react"; import { Link } from "react-router"; @@ -39,6 +33,9 @@ const capTitle = (id: string) => CAPABILITY_CATALOG[id as KnownCapabilityId]?.ti /** 默认上游(巡检没有单独的上游设置,跟随 Agent 设置里的默认上游) */ export type DefaultUpstream = { name: string; model: string | null } | null; +const modelsTitle = (models: { provider: string | null; model: string; count: number }[]) => + models.map((m) => `${m.provider ? `${m.provider} · ` : ""}${m.model}:${m.count} 次`).join("\n"); + function Field({ label, children }: { label: string; children: ReactNode }) { return ( <> @@ -51,7 +48,6 @@ function Field({ label, children }: { label: string; children: ReactNode }) { export function AgentCheckCard({ job, now, - runs, upstream, highlight, onEdit, @@ -59,8 +55,6 @@ export function AgentCheckCard({ }: { job: ScheduledJob; now: number; - /** 该巡检最近的会话(新 → 旧) */ - runs: AgentRun[]; upstream: DefaultUpstream; highlight: boolean; onEdit: (job: ScheduledJob) => void; @@ -77,9 +71,9 @@ export function AgentCheckCard({ const budget = c.budget ?? {}; const allow = c.capabilityAllowlist ?? []; const last = job.lastStatus ? RUN_STATUS[job.lastStatus] : null; - const latest = runs[0] ?? null; - const costs = runs.map((r) => r.usage.costUsd).filter((v): v is number => typeof v === "number"); - const avg = costs.length ? costs.reduce((s, v) => s + v, 0) / costs.length : null; + // 费用 / 模型由后端按最近 100 次会话汇总(GET /schedules 的 agentRuns) + const stats = job.agentRuns; + const runs = stats?.recent ?? []; const budgetParts = [ budget.maxToolCalls ? `${budget.maxToolCalls} 次调用` : null, budget.maxDurationSec ? `${budget.maxDurationSec} 秒` : null, @@ -149,10 +143,13 @@ export function AgentCheckCard({ {budgetParts.length ? budgetParts.join(" · ") : "默认"} - {latest?.model ? ( - - {latest.provider ? `${latest.provider} · ` : ""} - {latest.model} + {stats?.lastModel ? ( + + {stats.lastProvider ? `${stats.lastProvider} · ` : ""} + {stats.lastModel} + {stats.models.length > 1 ? ( + 等 {stats.models.length} 个模型 + ) : null} ) : upstream ? ( @@ -182,12 +179,12 @@ export function AgentCheckCard({ - {costs.length ? ( + {stats?.count ? ( - 上次 {formatUsd(costs[0])} + 上次 {formatUsd(stats.lastCostUsd)} {" "} - · 近 {costs.length} 次平均 {formatUsd(avg)} + · 近 {stats.count} 次平均 {formatUsd(stats.avgCostUsd)} ) : ( @@ -225,7 +222,7 @@ export function AgentCheckCard({ {plainSummary(r.summary ?? r.goal)} - {formatUsd(r.usage.costUsd)} + {formatUsd(r.costUsd)} {formatRelative(r.finishedAt ?? r.createdAt)} diff --git a/apps/web/app/components/schedules/schedule-board.tsx b/apps/web/app/components/schedules/schedule-board.tsx index 939ca7d3..ad04db55 100644 --- a/apps/web/app/components/schedules/schedule-board.tsx +++ b/apps/web/app/components/schedules/schedule-board.tsx @@ -3,7 +3,6 @@ import { SCHEDULE_CHANGED_EVENT, type ScheduledJob } from "@umt/contracts"; import { AlertTriangle, Bot, CalendarClock, CircleCheck, Coins, LoaderCircle, Plus, XCircle } from "lucide-react"; import { type ReactNode, useEffect, useRef, useState } from "react"; import { useLocation } from "react-router"; -import { groupRunsBySchedule, useScheduleAgentRuns } from "~/api/agent.ts"; import { usePiUpstreams } from "~/api/pi.ts"; import { useScheduleBoard } from "~/api/schedules.ts"; import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; @@ -50,10 +49,8 @@ export function useLiveSchedules() { return useScheduleBoard(); } -const WEEK_MS = 7 * 24 * 3600_000; - -/** 两节的计数:自动任务 启用/失败/运行中;巡检 启用/失败/近 7 天费用 */ -export function planStats(items: ScheduledJob[], checkCosts: { at: string; cost: number | undefined }[], now: number) { +/** 两节的计数:自动任务 启用/失败/运行中;巡检 启用/失败/近 7 天费用(后端按巡检汇总的 agentRuns) */ +export function planStats(items: ScheduledJob[]) { const auto = items.filter((j) => j.kind === "task"); const checks = items.filter((j) => j.kind === "agent_check"); const count = (list: ScheduledJob[]) => ({ @@ -62,13 +59,12 @@ export function planStats(items: ScheduledJob[], checkCosts: { at: string; cost: failed: list.filter((j) => j.lastStatus === "failed").length, running: list.filter((j) => !!j.runningSince).length, }); - const recent = checkCosts.filter((c) => now - new Date(c.at).getTime() <= WEEK_MS); return { auto: count(auto), checks: { ...count(checks), - weekRuns: recent.length, - weekCost: recent.reduce((s, c) => s + (c.cost ?? 0), 0), + weekRuns: checks.reduce((s, j) => s + (j.agentRuns?.weekCount ?? 0), 0), + weekCost: checks.reduce((s, j) => s + (j.agentRuns?.weekCostUsd ?? 0), 0), }, }; } @@ -89,7 +85,6 @@ function SectionHead({ id, title, hint, actions }: { id: string; title: string; export function PlanBoard({ highlightJob }: { highlightJob?: string }) { const board = useLiveSchedules(); - const agentRuns = useScheduleAgentRuns(); const upstreams = usePiUpstreams(); const now = useNow(); const { hash } = useLocation(); @@ -100,14 +95,9 @@ export function PlanBoard({ highlightJob }: { highlightJob?: string }) { const items = data?.items ?? []; const autoJobs = items.filter((j) => j.kind === "task"); const checks = items.filter((j) => j.kind === "agent_check"); - const runsByJob = groupRunsBySchedule(agentRuns.data ?? []); const def = upstreams.data?.items.find((u) => u.isDefault); const upstream: DefaultUpstream = def ? { name: def.name, model: def.defaultModel ?? null } : null; - const s = planStats( - items, - (agentRuns.data ?? []).map((r) => ({ at: r.createdAt, cost: r.usage.costUsd })), - now, - ); + const s = planStats(items); const val = (n: number) => (board.isPending ? "…" : board.isError ? "—" : n); // 首页计数链到 #auto / #checks:数据到了再滚过去 @@ -163,8 +153,8 @@ export function PlanBoard({ highlightJob }: { highlightJob?: string }) { jump("checks")} /> @@ -221,7 +211,6 @@ export function PlanBoard({ highlightJob }: { highlightJob?: string }) { key={j.id} job={j} now={now} - runs={runsByJob.get(j.id) ?? []} upstream={upstream} highlight={highlightJob === j.id} onEdit={(job) => setEditing({ job, key: Date.now() })} diff --git a/apps/web/app/routes/console/home.tsx b/apps/web/app/routes/console/home.tsx index 24130495..c729c9c6 100644 --- a/apps/web/app/routes/console/home.tsx +++ b/apps/web/app/routes/console/home.tsx @@ -22,7 +22,6 @@ import type { ReactNode } from "react"; import { useRef } from "react"; import { Link } from "react-router"; import { toast } from "sonner"; -import { useScheduleAgentRuns } from "~/api/agent.ts"; import { useDashboard, useSitesWithAccounts } from "~/api/dashboard.ts"; import { useDecisions } from "~/api/decisions.ts"; import { useEngineRunsPage, useEngineStats } from "~/api/engine.ts"; @@ -36,7 +35,7 @@ import { StatTile } from "~/components/console/stat-tile.tsx"; import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx"; import { useVolumeLabel } from "~/components/downloads/cache-volumes.tsx"; import { formatUsd, planPath } from "~/components/schedules/labels.ts"; -import { planStats, useLiveSchedules, useNow } from "~/components/schedules/schedule-board.tsx"; +import { planStats, useLiveSchedules } from "~/components/schedules/schedule-board.tsx"; import { engineRunStatus, runTitle, workflowLabel } from "~/components/tasks/labels.ts"; import { useLiveTasks } from "~/components/tasks/task-stream.tsx"; import { Button } from "~/components/ui/button.tsx"; @@ -353,13 +352,7 @@ function Num({ children, tone }: { children: ReactNode; tone?: "destructive" }) function PlanStrip() { const board = useLiveSchedules(); - const runs = useScheduleAgentRuns(); - const now = useNow(60_000); - const s = planStats( - board.data?.items ?? [], - (runs.data ?? []).map((r) => ({ at: r.createdAt, cost: r.usage.costUsd })), - now, - ); + const s = planStats(board.data?.items ?? []); return (
{s.checks.enabled} / {s.checks.total} - 近 7 天 {runs.isPending ? "…" : runs.isError ? "—" : formatUsd(s.checks.weekCost)} + 近 7 天 {formatUsd(s.checks.weekCost)} diff --git a/docs/runbook/schedules.md b/docs/runbook/schedules.md index b7385e86..9931696c 100644 --- a/docs/runbook/schedules.md +++ b/docs/runbook/schedules.md @@ -82,6 +82,8 @@ agent_run 结束后,调度器每 15 秒回填一次,把 `scheduled_job_run.a 用户可以通过 `POST /schedules` 新建自定义巡检,id 形如 `check.custom.<8位>`。自定义巡检可以改名,也可以删除;内置任务只能停用。 +`GET /schedules` 里每个巡检带 `agentRuns`:该巡检最近 100 次会话(`agent_run.created_by = schedule:`,不含子代理)的上次 / 平均费用、近 7 天会话数与费用、最近用的上游与模型、按模型计数、最近 3 次会话。计划页卡片与首页计划带直接用它,不再各自拉会话列表。 + ## 排查 - 看板顶部显示「调度器未在线」:说明没有进程持有锁。检查 worker(io 组)是否在运行,以及它的日志里有没有「调度器取得执行权」。 diff --git a/packages/contracts/src/schedules.ts b/packages/contracts/src/schedules.ts index 768a65e7..7999bb53 100644 --- a/packages/contracts/src/schedules.ts +++ b/packages/contracts/src/schedules.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { AgentBudget } from "./agent.ts"; +import { AgentBudget, AgentRunStatus } from "./agent.ts"; import { KnownCapabilityId } from "./capability.ts"; import { Timestamp } from "./common.ts"; @@ -67,6 +67,41 @@ export const AgentCheckConfig = z.object({ export type AgentCheckConfig = z.infer; export type AgentCheckConfigInput = z.input; +/** 巡检最近一次会话(卡片「最近会话」) */ +export const AgentCheckRecentRun = z.object({ + id: z.uuid(), + status: AgentRunStatus, + goal: z.string(), + summary: z.string().nullable(), + costUsd: z.number().nullable(), + createdAt: Timestamp, + finishedAt: Timestamp.nullable(), +}); +export type AgentCheckRecentRun = z.infer; + +/** + * 巡检会话的费用与模型汇总(INT-3):该巡检最近 100 次会话(agent_run.created_by = schedule:,不含子代理)。 + * 计划页卡片与首页计划带直接用,不再各自拉会话列表。 + */ +export const AgentCheckRunStats = z.object({ + /** 参与汇总的会话数(≤ 100) */ + count: z.number().int().nonnegative(), + lastCostUsd: z.number().nullable(), + /** 有费用记录的会话的平均费用;没有为 null */ + avgCostUsd: z.number().nullable(), + /** 近 7 天会话数与费用合计(最近 100 次以内) */ + weekCount: z.number().int().nonnegative(), + weekCostUsd: z.number().nonnegative(), + /** 最近一次有模型记录的会话用的上游 / 模型 */ + lastProvider: z.string().nullable(), + lastModel: z.string().nullable(), + /** 按模型分组的会话数(多 → 少) */ + models: z.array(z.object({ provider: z.string().nullable(), model: z.string(), count: z.number().int().positive() })), + /** 最近 3 次会话(新 → 旧) */ + recent: z.array(AgentCheckRecentRun), +}); +export type AgentCheckRunStats = z.infer; + export const ScheduledJob = z.object({ id: z.string().min(1), name: z.string().min(1), @@ -94,6 +129,8 @@ export const ScheduledJob = z.object({ failCount: z.number().int().nonnegative(), createdAt: Timestamp, updatedAt: Timestamp, + /** 只有 kind=agent_check 且在看板列表(GET /schedules)里带 */ + agentRuns: AgentCheckRunStats.optional(), }); export type ScheduledJob = z.infer; diff --git a/packages/db/migrations/0052_agent_run_created_by.ts b/packages/db/migrations/0052_agent_run_created_by.ts new file mode 100644 index 00000000..44396190 --- /dev/null +++ b/packages/db/migrations/0052_agent_run_created_by.ts @@ -0,0 +1,17 @@ +import type { Kysely } from "kysely"; +import { execDdl } from "../src/migration-helpers.ts"; + +// INT-3:计划页 GET /schedules 给每个 Agent 巡检汇总最近 100 次会话(created_by = schedule:)的费用与模型。 + +// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型 +export async function up(db: Kysely): Promise { + await execDdl( + db, + `create index if not exists agent_run_created_by_idx on agent_run (created_by, created_at desc) where created_by is not null;`, + ); +} + +// biome-ignore lint/suspicious/noExplicitAny: 同上 +export async function down(db: Kysely): Promise { + await execDdl(db, `drop index if exists agent_run_created_by_idx;`); +}