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:
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user