feat(agent): W4-E2 in-grant auto recovery for decisions with verified outcome

- settings.agent.autoInvestigateMode investigate|recover, investigateMaxAttempts, recoverVerifySec
- new capability download.recheck (execute, replay never): qB force recheck / resume, no source swap/delete
- autoInvestigate moves to agent/investigate.ts: recover mode runs execute with a recovery allowlist
  only when an active grant (published policy if bound) covers the recovery actions; otherwise degrades
  to read-only investigation and leaves suggestion + recheck option on the decision
- runAgent verifies recovery against business facts (download_task/task/source_release) before finalize;
  verified -> decision resolved by system:auto-recover, run summary '已恢复(系统核实)'; otherwise stays
  awaiting_user; per-root-cause attempt cap
- e2e E6: missingFiles -> download_failed -> recheck -> verified; no grant degrades; swap rejected;
  dedupe; attempts exhausted; budget exceeded keeps attention

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 22:35:03 +08:00
1 parent 23309042dc
commit 86af723b2a
13 files changed
+1072 -15

No files matched your search

@@ -0,0 +1,100 @@
import { createQbClient, type DownloaderClient } from "@umt/adapters";
import { CapabilityError, type CapabilityRegistry } from "@umt/agent";
import { capabilitySpec, type DownloadStatus } from "@umt/contracts";
import type { DownloaderInstanceRow } from "@umt/db";
import type { ServerContext } from "../types.ts";
import type { AgentModuleOptions } from "./runner.ts";
// download.recheck(REQ §8.4.2 / §10.7 授权内恢复):对已纳管的下载任务发起 qB 强制重新校验或恢复暂停。
// 只动下载器里的这一个种子:不换源、不删除、不改保存路径 / 分类 / 标签;下载任务状态仍由后台同步按 qB 实际状态更新,
// 是否"已恢复"由 resolveDecision 的核实步骤看 download_task 状态决定,本能力只返回"已发出请求"。
/** recheck 允许的起点:文件缺失 / 出错 / 无进展 / 暂停(非阈值、非人工) */
const RECHECK_FROM: DownloadStatus[] = ["missing", "failed", "stalled", "paused"];
/** resume 允许的起点 */
const RESUME_FROM: DownloadStatus[] = ["paused", "stalled", "failed"];
async function defaultClient(ctx: ServerContext, inst: DownloaderInstanceRow): Promise<DownloaderClient> {
const settings = (inst.settings ?? {}) as Record<string, unknown>;
const secret = inst.secret_ref ? await ctx.secrets.resolve(inst.secret_ref) : undefined;
return createQbClient({
instanceId: inst.id,
baseUrl: inst.base_url,
...(inst.username ? { username: inst.username } : {}),
...(secret ? (settings.authKind === "api_key" ? { apiKey: secret } : { password: secret }) : {}),
});
}
/** 持续授权的对象范围:按下载任务 → 计划 → Slot → 作品核对(authorize 对非计划类能力拿不到目标作品) */
async function checkGrantScope(ctx: ServerContext, grantId: string, planId: string | null) {
const g = await ctx.db.selectFrom("automation_grant").select("scope").where("id", "=", grantId).executeTakeFirst();
const scope = (g?.scope ?? {}) as { mediaEntityIds?: string[]; mediaKinds?: string[] };
if (!scope.mediaEntityIds?.length && !scope.mediaKinds?.length) return;
const e = planId
? await ctx.db
.selectFrom("acquisition_plan as p")
.innerJoin("acquisition_slot as s", "s.id", "p.slot_id")
.innerJoin("media_entity as e", "e.id", "s.media_entity_id")
.select(["e.id", "e.kind"])
.where("p.id", "=", planId)
.executeTakeFirst()
: undefined;
if (!e) throw new CapabilityError("unauthorized", `授权 ${grantId} 限定了对象范围,但下载任务没有关联作品`);
if (scope.mediaEntityIds?.length && !scope.mediaEntityIds.includes(e.id))
throw new CapabilityError("unauthorized", `授权 ${grantId} 的对象范围不含目标作品`);
if (scope.mediaKinds?.length) {
const ok =
scope.mediaKinds.includes(e.kind) ||
(e.kind !== "movie" && e.kind !== "variety" && scope.mediaKinds.includes("series"));
if (!ok) throw new CapabilityError("unauthorized", `授权 ${grantId} 的媒体类型范围不含 ${e.kind}`);
}
}
export function registerDownloadRecoveryCapabilities(
registry: CapabilityRegistry,
ctx: ServerContext,
opts: AgentModuleOptions,
): void {
registry.register(capabilitySpec("download.recheck"), async (input, c) => {
const dt = await ctx.db
.selectFrom("download_task")
.selectAll()
.where("id", "=", input.downloadTaskId)
.executeTakeFirst();
if (!dt) throw new CapabilityError("not_found", "下载任务不存在");
if (!dt.managed || dt.removed_at || dt.status === "removed")
throw new CapabilityError("precondition", "未纳管或已移除的下载任务不做自动恢复");
const status = dt.status as DownloadStatus;
if (input.action === "recheck" && !RECHECK_FROM.includes(status))
throw new CapabilityError("precondition", `当前状态 ${status} 不需要重新校验`);
if (input.action === "resume" && !RESUME_FROM.includes(status))
throw new CapabilityError("precondition", `当前状态 ${status} 不需要恢复`);
// 阈值暂停不被绕过;人工暂停只由人恢复
if (status === "paused" && (dt.pause_reason === "threshold" || dt.pause_reason === "user"))
throw new CapabilityError(
"precondition",
`${dt.pause_reason === "threshold" ? "缓存阈值" : "人工"}暂停不做自动恢复`,
);
if (c.authorization?.grantId) await checkGrantScope(ctx, c.authorization.grantId, dt.acquisition_plan_id);
const inst = await ctx.db
.selectFrom("downloader_instance")
.selectAll()
.where("id", "=", dt.instance_id)
.executeTakeFirst();
if (!inst) throw new CapabilityError("not_found", "下载器实例不存在");
const client = opts.downloader ? await opts.downloader(inst) : await defaultClient(ctx, inst);
try {
if (input.action === "recheck") await client.recheck([dt.torrent_hash]);
else await client.resume([dt.torrent_hash]);
} catch (err) {
throw new CapabilityError("internal", `下载器请求失败:${(err as Error).message}`);
}
return {
downloadTaskId: dt.id,
action: input.action,
statusBefore: status,
rawStateBefore: dt.raw_state,
requested: true,
};
});
}