Files
UHD_Media_Tool/apps/server/src/modules/agent/investigate.ts
T
rootandClaude Opus 5.5 fb84a71f80 style(copy): fix remaining punctuation violations and make check:copy strict
Auto-fix of 602 string literals across 74 files (half-width punctuation next to
Chinese, parenthesised Chinese, quotes); biome reflow; e2/e6 assertions follow
the new full-width summaries. check:copy now runs with --strict in ci.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 21:34:51 +08:00

450 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { DecisionOption, DownloadStatus } from "@umt/contracts";
import { type DecisionRow, sql } from "@umt/db";
import type { ServerContext } from "../types.ts";
import { GRANT_ACTIONS } from "./authorize.ts";
import { type DriveResult, INVESTIGATE_CAPABILITIES, RECOVER_CAPABILITIES, RECOVERY_ACTIONS } from "./runner.ts";
import { startRun } from "./service.ts";
// 后台自动调查与授权内恢复(REQ §8.4.2、§10.7、AT68 / AT71):
// decision.created → autoInvestigate:按 settings.agent.autoInvestigateMode 决定只读调查或授权内恢复,
// recover 需要有覆盖恢复动作的有效持续授权(策略版本须为 published),否则降级为只读调查并在事项上留建议;
// 同一 decision 只一个 run(dedupe_key=investigate:<id>),同一根因(decision 去重键)的恢复次数有上限。
// runAgent 结束 → recoveryActions / checkRecovery / applyRecoveryOutcome:
// 按业务事实(download_task / task / source_release 状态)核实恢复结果,核实成功才关闭事项;
// 失败 / 超时 / 预算超限一律保持待处理(finalize 把事项转为 awaiting_user),不自动再试。
const KINDS = ["identity_ambiguous", "download_failed", "download_stalled"] as const;
export const AUTO_ACTOR = "system:auto-investigate";
export const RECOVER_ACTOR = "system:auto-recover";
/** 各类事项在 recover 模式下需要被授权覆盖的恢复动作 */
const ACTIONS_FOR: Record<string, readonly string[]> = {
download_failed: ["download.recheck", "task.retry"],
download_stalled: ["download.recheck", "task.retry"],
identity_ambiguous: ["identity.resolve"],
};
/** 下载任务处于这些状态即视为已恢复(qB 重新开始下载或已经完成) */
const DL_RECOVERED: DownloadStatus[] = ["downloading", "queued", "completed", "importing", "imported"];
export interface AutoRecoverEvidence {
mode: "investigate" | "recover";
requestedMode: "investigate" | "recover";
/** 本根因第几次自动恢复(只在 recover 时计数) */
attempt: number;
maxAttempts: number;
recoverAttempted: boolean;
degraded?: { reason: string } | undefined;
skipped?: string | undefined;
runId?: string | null;
outcome?: RecoveryOutcomeKind;
actions?: RecoveryAction[];
denied?: { capabilityId: string; reason: string | null }[];
facts?: Record<string, unknown>;
suggestion?: string;
verifiedAt?: string;
}
type RecoveryOutcomeKind = "recovered" | "failed" | "unverified" | "not_attempted" | "budget_exceeded" | "cancelled";
export interface RecoveryAction {
capabilityId: string;
input: Record<string, unknown>;
status: string;
}
export interface RecoveryCheck {
state: "recovered" | "failed" | "pending";
facts: Record<string, unknown>;
}
export interface RecoveryOutcome {
decisionId: string;
outcome: RecoveryOutcomeKind;
summary: string;
}
const decisionIdOf = (dedupeKey: string | null | undefined) =>
dedupeKey?.startsWith("investigate:") ? dedupeKey.slice("investigate:".length) : null;
export const isInvestigationRun = (run: { dedupe_key: string | null }) => decisionIdOf(run.dedupe_key) !== null;
async function mergeEvidence(ctx: ServerContext, decisionId: string, patch: Partial<AutoRecoverEvidence>) {
await ctx.db
.updateTable("decision")
.set({
evidence: sql`evidence || jsonb_build_object('autoRecover',
coalesce(evidence->'autoRecover', '{}'::jsonb) || ${JSON.stringify(patch)}::jsonb)`,
})
.where("id", "=", decisionId)
.execute();
}
/** 有没有覆盖这些动作的有效持续授权(未撤销、未过期;绑定策略时策略须仍是 published) */
export async function recoveryGrants(ctx: ServerContext, capabilityIds: readonly string[]): Promise<string[]> {
const actions = [...new Set([...capabilityIds.flatMap((c) => GRANT_ACTIONS[c] ?? [c]), "*"])];
const rows = await ctx.db
.selectFrom("automation_grant as g")
.leftJoin("policy_revision as p", "p.id", "g.policy_revision_id")
.select(["g.id", "g.scope"])
.where("g.status", "=", "active")
.where("g.action", "in", actions)
.where((eb) => eb.or([eb("g.expires_at", "is", null), eb("g.expires_at", ">", new Date())]))
.where((eb) => eb.or([eb("g.policy_revision_id", "is", null), eb("p.status", "=", "published")]))
.execute();
return rows
.filter((g) => {
const caps = ((g.scope ?? {}) as { capabilities?: string[] }).capabilities;
return !caps?.length || caps.some((c) => capabilityIds.includes(c));
})
.map((g) => g.id);
}
/** 下载类事项关联的业务任务(提交下载 / 核验入库等),给模型 task.inspect / task.retry 用 */
async function relatedTask(ctx: ServerContext, d: DecisionRow) {
if (d.task_id) return d.task_id;
if (d.subject_type !== "download_task" || !d.subject_id) return null;
const dt = await ctx.db
.selectFrom("download_task")
.select(["acquisition_plan_id"])
.where("id", "=", d.subject_id)
.executeTakeFirst();
const t = await ctx.db
.selectFrom("task")
.select("id")
.where((eb) =>
eb.or([
eb.and([eb("subject_type", "=", "download_task"), eb("subject_id", "=", d.subject_id!)]),
...(dt?.acquisition_plan_id
? [eb.and([eb("subject_type", "=", "acquisition_plan"), eb("subject_id", "=", dt.acquisition_plan_id)])]
: []),
]),
)
.orderBy("created_at", "desc")
.executeTakeFirst();
return t?.id ?? null;
}
function suggestionFor(d: DecisionRow, reason: string): string {
if (d.kind === "identity_ambiguous")
return `${reason}。可在待处理中手动选择身份,或为 identity.resolve 发放持续授权。`;
return (
`${reason}。可在待处理中选择"强制重新校验"(download.recheck),或为 download.recheck / task.retry 发放持续授权;` +
`换源、删除需人工决定。`
);
}
/** 事项上补一个可直接执行的"强制重新校验"选项(人工点选仍经授权 / 审计 / 账本) */
async function addRecheckOption(ctx: ServerContext, d: DecisionRow) {
if (d.subject_type !== "download_task" || !d.subject_id) return;
const opts = (d.options ?? []) as DecisionOption[];
if (opts.some((o) => o.key === "recheck")) return;
const next: DecisionOption[] = [
...opts,
{
key: "recheck",
label: "强制重新校验",
description: "让下载器重新校验已有文件(不换源、不删除)",
capabilityId: "download.recheck",
input: { downloadTaskId: d.subject_id, action: "recheck" },
},
];
await ctx.db
.updateTable("decision")
.set({ options: JSON.stringify(next) })
.where("id", "=", d.id)
.execute();
}
/**
* decision.created → 有界调查 / 授权内恢复。同一 decision 只一个 run;
* 同一根因(去重键)已自动恢复过 maxAttempts 次则不再唤醒模型,直接交还人工。
*/
export async function autoInvestigate(
ctx: ServerContext,
decisionId: string,
): Promise<{ runId: string | null; created: boolean; mode?: "investigate" | "recover"; skipped?: string }> {
const d = await ctx.db.selectFrom("decision").selectAll().where("id", "=", decisionId).executeTakeFirst();
if (d?.status !== "open") return { runId: null, created: false };
if (!(KINDS as readonly string[]).includes(d.kind)) return { runId: null, created: false };
// Agent 自己引起的事项不再触发 Agent(防止递归自触发)
if (d.subject_type === "agent_run") return { runId: null, created: false };
const s = await ctx.settings.get("agent");
if (!s.autoInvestigate) return { runId: null, created: false };
const requested = s.autoInvestigateMode;
let mode: "investigate" | "recover" = requested;
let degraded: { reason: string } | undefined;
let attempt = 0;
if (requested === "recover") {
// 同根因的历史恢复次数(包括已关闭的事项)
const prior = await sql<{ n: number }>`
select count(*)::int as n from decision
where dedupe_key = ${d.dedupe_key} and id <> ${d.id}
and (evidence->'autoRecover'->>'recoverAttempted')::boolean is true`.execute(ctx.db);
attempt = (prior.rows[0]?.n ?? 0) + 1;
if (attempt > s.investigateMaxAttempts) {
const reason = `同一根因已自动恢复 ${attempt - 1} 次(上限 ${s.investigateMaxAttempts}),不再自动处理`;
await mergeEvidence(ctx, d.id, {
mode: "investigate",
requestedMode: requested,
attempt: attempt - 1,
maxAttempts: s.investigateMaxAttempts,
recoverAttempted: false,
skipped: "attempts_exhausted",
suggestion: suggestionFor(d, reason),
});
await addRecheckOption(ctx, d);
const r = await ctx.db
.updateTable("decision")
.set({ status: "awaiting_user" })
.where("id", "=", d.id)
.where("status", "=", "open")
.returning("id")
.executeTakeFirst();
if (r)
await ctx.outbox.emit(
ctx.db,
"decision.updated",
{ decisionId: d.id, status: "awaiting_user" },
{ idempotencyKey: `decision.updated:${d.id}:attempts_exhausted` },
);
return { runId: null, created: false, skipped: "attempts_exhausted" };
}
const grants = await recoveryGrants(ctx, ACTIONS_FOR[d.kind] ?? []);
if (grants.length === 0) {
mode = "investigate";
degraded = { reason: `没有覆盖 ${(ACTIONS_FOR[d.kind] ?? []).join(" / ")} 的有效持续授权` };
}
}
const dt =
d.subject_type === "download_task" && d.subject_id
? await ctx.db
.selectFrom("download_task")
.select(["id", "name", "status", "raw_state", "torrent_hash"])
.where("id", "=", d.subject_id)
.executeTakeFirst()
: undefined;
const taskId = await relatedTask(ctx, d);
const goal = [
`调查待处理事项 ${d.id}(${d.kind}):${d.title}。`,
d.summary ? `摘要:${d.summary}。` : "",
d.subject_type && d.subject_id ? `对象:${d.subject_type} ${d.subject_id}。` : "",
dt ? `下载任务 ${dt.id}「${dt.name ?? ""}」当前 ${dt.status}(qB 状态 ${dt.raw_state ?? "未知"})。` : "",
taskId ? `关联任务 ${taskId}。` : "",
mode === "recover"
? "在授权范围内做有界恢复:先用 task.inspect 等只读能力查证,再按需调用 download.recheck(强制重新校验 / 恢复暂停)、" +
"task.retry(重试失败任务)或 identity.resolve,每种最多一次;不换源、不删除、不清理;恢复结果由系统核实。"
: "只做只读调查:查证据,给出结论与处理建议;不提交下载,不改文件。",
].join("");
const { run, created } = await startRun(ctx, {
goal,
mode: mode === "recover" ? "execute" : "plan_only",
capabilityAllowlist: mode === "recover" ? [...RECOVER_CAPABILITIES] : [...INVESTIGATE_CAPABILITIES],
trigger: "event",
createdBy: mode === "recover" ? RECOVER_ACTOR : AUTO_ACTOR,
dedupeKey: `investigate:${d.id}`,
budget: { maxToolCalls: s.investigateMaxToolCalls, maxDurationSec: Math.min(600, s.maxDurationSec) },
});
if (created) {
const r = await ctx.db
.updateTable("decision")
.set({ status: "investigating", agent_run_id: run.id })
.where("id", "=", d.id)
.where("status", "=", "open")
.returning("id")
.executeTakeFirst();
if (r) {
await mergeEvidence(ctx, d.id, {
mode,
requestedMode: requested,
attempt,
maxAttempts: s.investigateMaxAttempts,
recoverAttempted: mode === "recover",
...(degraded ? { degraded, suggestion: suggestionFor(d, degraded.reason) } : {}),
runId: run.id,
});
if (degraded) await addRecheckOption(ctx, d);
}
}
return { runId: run.id, created, mode };
}
/** 本 run 调用过的恢复动作(账本)与被拒绝的调用(审计) */
export async function recoveryActions(
ctx: ServerContext,
runId: string,
): Promise<{ actions: RecoveryAction[]; denied: { capabilityId: string; reason: string | null }[] }> {
const rows = await ctx.db
.selectFrom("capability_invocation")
.select(["capability_id", "input", "status"])
.where("agent_run_id", "=", runId)
.where("capability_id", "in", [...RECOVERY_ACTIONS])
.orderBy("created_at")
.execute();
const denied = await ctx.db
.selectFrom("audit_event")
.select(["target_id", sql<string | null>`payload->>'errorMessage'`.as("reason")])
.where("action", "=", "capability.denied")
.where("trace_id", "=", runId)
.where(sql<string>`payload->>'agentRunId'`, "=", runId)
.orderBy("at")
.execute();
return {
actions: rows.map((r) => ({
capabilityId: r.capability_id,
input: (r.input ?? {}) as Record<string, unknown>,
status: r.status,
})),
denied: denied.map((x) => ({ capabilityId: x.target_id ?? "", reason: x.reason })),
};
}
/** 按业务事实核实恢复动作:全部已恢复 → recovered;任一明确失败 → failed;否则 pending(继续等) */
export async function checkRecovery(ctx: ServerContext, actions: RecoveryAction[]): Promise<RecoveryCheck> {
const facts: Record<string, unknown> = {};
const states: RecoveryCheck["state"][] = [];
for (const a of actions.filter((x) => x.status === "succeeded")) {
if (a.capabilityId === "download.recheck") {
const id = String(a.input.downloadTaskId ?? "");
const dt = await ctx.db
.selectFrom("download_task")
.select(["status", "raw_state", "progress"])
.where("id", "=", id)
.executeTakeFirst();
facts[`download_task:${id}`] = dt ? { status: dt.status, rawState: dt.raw_state } : null;
states.push(
!dt || dt.status === "removed"
? "failed"
: DL_RECOVERED.includes(dt.status as DownloadStatus)
? "recovered"
: "pending",
);
} else if (a.capabilityId === "task.retry") {
const id = String(a.input.taskId ?? "");
const t = await ctx.db.selectFrom("task").select(["status"]).where("id", "=", id).executeTakeFirst();
facts[`task:${id}`] = t ? { status: t.status } : null;
states.push(
t?.status === "succeeded"
? "recovered"
: !t || ["failed", "dead", "cancelled"].includes(t.status)
? "failed"
: "pending",
);
} else if (a.capabilityId === "identity.resolve") {
const id = String(a.input.sourceReleaseId ?? "");
const r = id
? await ctx.db
.selectFrom("source_release")
.select(["resolution_status", "media_entity_id"])
.where("id", "=", id)
.executeTakeFirst()
: undefined;
facts[`source_release:${id}`] = r ? { resolutionStatus: r.resolution_status } : null;
states.push(r?.resolution_status === "resolved" && r.media_entity_id ? "recovered" : "pending");
}
}
const state = states.includes("failed") ? "failed" : states.every((x) => x === "recovered") ? "recovered" : "pending";
return { state, facts };
}
/**
* 核实结论写回:recovered → 事项 resolved(resolution.execution 记恢复动作,executeResolution 不会重复执行);
* 其余 → 只写证据,事项保持 investigating,由 finalize 转 awaiting_user(交还人工,不自动再试)。
*/
export async function applyRecoveryOutcome(
ctx: ServerContext,
runId: string,
res: DriveResult,
acts: { actions: RecoveryAction[]; denied: { capabilityId: string; reason: string | null }[] },
check: RecoveryCheck | null,
): Promise<RecoveryOutcome | null> {
const run = await ctx.db
.selectFrom("agent_run")
.select(["dedupe_key", "cancel_requested_at"])
.where("id", "=", runId)
.executeTakeFirstOrThrow();
const decisionId = decisionIdOf(run.dedupe_key);
if (!decisionId) return null;
const d = await ctx.db.selectFrom("decision").selectAll().where("id", "=", decisionId).executeTakeFirst();
if (!d) return null;
const done = acts.actions.filter((a) => a.status === "succeeded");
let outcome: RecoveryOutcomeKind;
if (res.status === "cancelled" || run.cancel_requested_at) outcome = "cancelled";
else if (check?.state === "recovered") outcome = "recovered";
else if (check?.state === "failed") outcome = "failed";
else if (done.length > 0) outcome = "unverified";
else if (res.reason === "budget") outcome = "budget_exceeded";
else outcome = "not_attempted";
const what = done.map((a) => a.capabilityId).join(" + ");
const summary =
outcome === "recovered"
? `已恢复(系统核实):执行 ${what} 后业务状态已恢复。`
: outcome === "failed"
? `未恢复:执行 ${what} 后业务状态仍为失败,已退回待处理。`
: outcome === "unverified"
? `未恢复:执行 ${what} 后在核实时限内业务状态未恢复,已退回待处理。`
: outcome === "budget_exceeded"
? `未恢复:调查超出预算(${res.message ?? ""}),未执行恢复动作,已退回待处理。`
: outcome === "cancelled"
? "未恢复:运行已取消。"
: `未恢复:没有执行恢复动作${acts.denied.length ? `(被拒绝:${acts.denied.map((x) => x.capabilityId).join(", ")})` : ""},已退回待处理。`;
const evidence: Partial<AutoRecoverEvidence> = {
runId,
outcome,
actions: acts.actions,
denied: acts.denied,
facts: check?.facts ?? {},
verifiedAt: new Date().toISOString(),
...(outcome === "recovered" ? {} : { suggestion: suggestionFor(d, summary.replace(/。$/, "")) }),
};
await mergeEvidence(ctx, decisionId, evidence);
if (outcome === "recovered") {
await ctx.db.transaction().execute(async (trx) => {
const r = await trx
.updateTable("decision")
.set({
status: "resolved",
resolved_by: RECOVER_ACTOR,
resolved_at: new Date(),
resolution: JSON.stringify({
optionKey: null,
by: RECOVER_ACTOR,
note: summary,
execution: { kind: "agent_recover", runId, actions: done, verified: true, facts: check?.facts ?? {} },
}),
})
.where("id", "=", decisionId)
.where("status", "in", ["investigating", "open", "awaiting_user"])
.returning("id")
.executeTakeFirst();
if (r)
await ctx.outbox.emit(
trx,
"decision.updated",
{ decisionId, status: "resolved" },
{ idempotencyKey: `decision.updated:${decisionId}:recovered:${runId}` },
);
});
} else {
const fresh = await ctx.db.selectFrom("decision").selectAll().where("id", "=", decisionId).executeTakeFirst();
if (fresh) await addRecheckOption(ctx, fresh);
}
return { decisionId, outcome, summary };
}
/** finalize 之后:把系统核实结论放在 run 汇总最前面(模型文本仍标注"未经核查") */
export async function annotateRunSummary(ctx: ServerContext, runId: string, rec: RecoveryOutcome) {
const run = await ctx.db
.selectFrom("agent_run")
.select(["summary", "status"])
.where("id", "=", runId)
.executeTakeFirstOrThrow();
if (run.summary?.startsWith(rec.summary)) return { status: run.status, summary: run.summary };
const summary = `${rec.summary}\n${run.summary ?? ""}`.slice(0, 4000);
await ctx.db.updateTable("agent_run").set({ summary }).where("id", "=", runId).execute();
void ctx.events.publish("agent.run_status", { runId, status: run.status, summary: summary.slice(0, 2000) });
return { status: run.status, summary };
}