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

@@ -16,6 +16,7 @@ export const GRANT_ACTIONS: Record<string, readonly string[]> = {
"identity.resolve": ["identity.resolve", "policy.identity"],
"download.submit": ["download.submit", "policy.acquisition"],
"organize.execute": ["organize.execute", "policy.acquisition", "policy.organize"],
"download.recheck": ["download.recheck", "policy.automation"],
"task.retry": ["task.retry", "task.*"],
"task.cancel": ["task.cancel", "task.*"],
"policy.apply": ["policy.apply"],
@@ -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,
};
});
}
@@ -7,7 +7,7 @@ import {
type IdempotencyKeyGenerator,
stableStringify,
} from "@umt/agent";
import type { ActorType, CapabilityKind, CapabilitySpec } from "@umt/contracts";
import { type ActorType, CAPABILITY_IDS, type CapabilityKind, type CapabilitySpec } from "@umt/contracts";
import { type DB, type Kysely, sql } from "@umt/db";
import type { Logger } from "pino";
import type { z } from "zod";
@@ -20,7 +20,7 @@ import { createAuthorizer } from "./authorize.ts";
const SIDE_EFFECT: readonly CapabilityKind[] = ["propose", "approve", "execute"];
/** 首批能力总数:启动时断言注册数与此一致(漏挂处理器即启动失败) */
export const EXPECTED_CAPABILITY_COUNT = 14;
export const EXPECTED_CAPABILITY_COUNT = CAPABILITY_IDS.length;
/**
* 幂等键:能力 ID + 版本 + 所属运行(agent run / 任务)+ 规范化输入的 sha256。
@@ -187,7 +187,7 @@ export function createCapabilityRegistry(deps: CapabilityRegistryDeps): Capabili
});
}
/** 启动断言:首批 14 个能力全部挂上处理器 */
/** 启动断言:首批能力全部挂上处理器 */
export function assertCapabilityCount(registry: CapabilityRegistry): void {
const n = registry.list().length;
if (n !== EXPECTED_CAPABILITY_COUNT) {
+2
View File
@@ -1,5 +1,6 @@
import type { ServerContext, ServerModule } from "../types.ts";
import { registerTaskCapabilities } from "./capabilities.ts";
import { registerDownloadRecoveryCapabilities } from "./capabilities-download.ts";
import { registerAgentRoutes } from "./routes.ts";
import type { AgentEnv, AgentModuleOptions } from "./runner.ts";
import { agentOutboxRoutes, registerAgentTasks } from "./tasks.ts";
@@ -34,6 +35,7 @@ export function createAgentModule(opts: AgentModuleOptions = {}): ServerModule &
outboxRoutes: () => agentOutboxRoutes,
registerCapabilities(registry, ctx) {
registerTaskCapabilities(registry, ctx);
registerDownloadRecoveryCapabilities(registry, ctx, opts);
},
};
}
@@ -0,0 +1,449 @@
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),或为自动恢复发放持续授权` +
`(action=download.recheck / task.retry,可绑定已发布的 automation 策略);换源、删除仍需人工决定。`
);
}
/** 事项上补一个可直接执行的"强制重新校验"选项(人工点选仍经授权 / 审计 / 账本) */
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 };
}
+18 -2
View File
@@ -1,4 +1,5 @@
import { join, resolve } from "node:path";
import type { DownloaderClient } from "@umt/adapters";
import {
type AgentRunHandle,
type AgentSettings,
@@ -21,7 +22,7 @@ import {
type GlobalSettings,
KnownCapabilityId,
} from "@umt/contracts";
import { type AgentRunRow, sql } from "@umt/db";
import { type AgentRunRow, type DownloaderInstanceRow, sql } from "@umt/db";
import type { ServerContext } from "../types.ts";
import { DEMO_SCRIPTS, parseFakeScripts } from "./fake-scripts.ts";
import { buildGoalReport } from "./report.ts";
@@ -36,6 +37,8 @@ export interface AgentModuleOptions {
fakeScripts?: FakeScript[];
/** 覆盖 settings.agent.goalPollSec(测试用小值) */
goalPollSec?: number;
/** download.recheck 用的下载器客户端;缺省按实例配置 + secret 现场创建 qB 客户端 */
downloader?: (inst: DownloaderInstanceRow) => Promise<DownloaderClient>;
}
export interface AgentEnv {
@@ -54,6 +57,14 @@ export const INVESTIGATE_CAPABILITIES = [
"task.inspect",
"identity.resolve",
] as const;
/**
* 授权内恢复(settings.agent.autoInvestigateMode = recover)的能力白名单:只读调查 + 恢复类
* (重试失败任务、强制重新校验 / 恢复暂停)。换源(download.plan / download.submit)、删除 / 清理、
* 取消任务、整理执行、策略发布一律不在白名单内。实际能否执行仍由 authorize 按持续授权判定。
*/
export const RECOVER_CAPABILITIES = [...INVESTIGATE_CAPABILITIES, "task.retry", "download.recheck"] as const;
/** 被视为"恢复动作"的能力(结果需系统核实) */
export const RECOVERY_ACTIONS = ["task.retry", "download.recheck", "identity.resolve"] as const;
export interface RunScope {
capabilities: string[];
@@ -243,7 +254,12 @@ function systemPromptFor(run: AgentRunRow, scope: RunScope): string {
];
if (run.mode === "plan_only")
constraints.push("本次为“先给方案”:只调查与规划,不提交下载、不做文件变更、不发布策略。");
if (run.trigger === "event")
if (run.trigger === "event" && run.mode === "execute")
constraints.push(
"本任务由后台事件触发,是有界的授权内恢复:先查证据,再只在授权范围内执行恢复(task.retry / download.recheck / identity.resolve)," +
"每种恢复动作最多一次;不换源、不删除、不清理;恢复是否成功由系统按业务状态核实,不要自行宣称已恢复。",
);
else if (run.trigger === "event")
constraints.push("本任务由后台事件触发,是有界的只读调查:收集证据、给出结论与建议,不要反复重试同一操作。");
return buildSystemPrompt({
role: run.trigger === "event" ? "媒体库异常调查员" : "媒体库自动化助手",
+40 -4
View File
@@ -4,9 +4,19 @@ import { defineTask, type OutboxRouteFn, type TaskRegistry } from "@umt/tasks";
import { z } from "zod";
import type { ServerContext } from "../types.ts";
import { retryTask } from "./capabilities.ts";
import {
annotateRunSummary,
applyRecoveryOutcome,
autoInvestigate,
checkRecovery,
isInvestigationRun,
type RecoveryCheck,
type RecoveryOutcome,
recoveryActions,
} from "./investigate.ts";
import { buildGoalReport } from "./report.ts";
import { type AgentEnv, type DriveResult, drive, goalSettled } from "./runner.ts";
import { autoInvestigate, finalize } from "./service.ts";
import { finalize } from "./service.ts";
// 持久任务:runAgent(驱动一次有界 Agent 会话 → 等目标关联的下载/入库落定 → 以业务表汇报)
// 与 resolveDecision(decision.created → 自动调查;decision.updated(resolved)→ 执行所选处理)。
@@ -37,10 +47,31 @@ export function registerAgentTasks(registry: TaskRegistry, env: () => AgentEnv)
let settled: AgentGoalReport | null = null;
const run = await e.ctx.db
.selectFrom("agent_run")
.select(["mode"])
.select(["mode", "dedupe_key"])
.where("id", "=", runId)
.executeTakeFirstOrThrow();
if (res.status === "completed" && run.mode === "execute") {
// 后台授权内恢复:核实业务事实(下载 / 任务 / 身份状态)后再收尾;不等"目标入库"
const recovering = run.mode === "execute" && isInvestigationRun(run);
let recovery: RecoveryOutcome | null = null;
if (recovering) {
const acts = await t.step.run("recover-actions", () => recoveryActions(e.ctx, runId));
let check: RecoveryCheck | null = null;
if (res.status === "completed" && acts.actions.some((a) => a.status === "succeeded")) {
const s = await e.ctx.settings.get("agent");
const pollSec = Math.min(e.opts.goalPollSec ?? s.goalPollSec, 30);
check = await t.step.waitForFact<RecoveryCheck>("recover-verified", {
check: async () => {
const c = await checkRecovery(e.ctx, acts.actions);
return c.state === "pending" ? null : c;
},
timeout: `${pollSec}s`,
maxRounds: Math.max(1, Math.min(900, Math.ceil(s.recoverVerifySec / pollSec))),
});
// 超时:用最后一次观测作为证据(state 仍是 pending → unverified)
check ??= await t.step.run("recover-last-check", () => checkRecovery(e.ctx, acts.actions));
}
recovery = await t.step.run("recover-outcome", () => applyRecoveryOutcome(e.ctx, runId, res, acts, check));
} else if (res.status === "completed" && run.mode === "execute") {
const s = await e.ctx.settings.get("agent");
const pollSec = e.opts.goalPollSec ?? s.goalPollSec;
const rounds = Math.max(1, Math.min(900, Math.ceil((s.goalWaitHours * 3600) / pollSec)));
@@ -59,7 +90,12 @@ export function registerAgentTasks(registry: TaskRegistry, env: () => AgentEnv)
maxRounds: rounds,
});
}
return t.step.run("finalize", () => finalize(e, runId, res, settled));
const out = await t.step.run("finalize", () => finalize(e, runId, res, settled));
if (recovery) {
const rec = recovery;
return t.step.run("recover-summary", () => annotateRunSummary(e.ctx, runId, rec));
}
return out;
},
}),
defineTask({