fix(server,test): W6-A stabilize CI — sync lost update, atomic recovery summary, serial PG tests

- downloads.ts observe: re-read download_task FOR UPDATE inside the tx and merge
  from the locked row; qB sync could overwrite imported back to importing when
  verifyAndImport committed between the sync read and write (E8 flake).
- agent finalize: accept summaryPrefix so the recovery verdict is written with
  the terminal status in one update (E6 read a budget_exceeded run before the
  "未恢复:…" annotation landed); recover-summary step kept, now idempotent no-op.
- organize.ts: drop local slotComplete and the once-subscription completion
  write; completion/history are decided only by applySlotEvent(verify_passed)
  (W5-C follow-up, removes double write). Manual slot organize uses the shared
  subscriptions.slotComplete.
- vitest: server split into unit (parallel) and pg (file-serial, groupOrder 1)
  projects; packages/tasks file-serial; explicit worker concurrency in e2e.
- docs: W6 stabilization record in 04-v0.1-acceptance.md.

Test expectations unchanged. Full `deno task ci` x3 green: 369s / 341s / 337s.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 00:32:57 +08:00
1 parent 36aa99b8aa
commit 887843f777
10 files changed
+198 -143

No files matched your search

@@ -659,7 +659,29 @@ async function syncInstance(d: AoDeps, topo: InstanceTopology, stats: SyncStats)
const sizeOf = (t: QbTorrent) => (t.totalSize ?? t.size) || null; const sizeOf = (t: QbTorrent) => (t.totalSize ?? t.size) || null;
const downloadedOf = (t: QbTorrent) => Math.max(0, Math.round((sizeOf(t) ?? 0) - t.amountLeft)); const downloadedOf = (t: QbTorrent) => Math.max(0, Math.round((sizeOf(t) ?? 0) - t.amountLeft));
async function observe(d: AoDeps, row: DownloadTaskRow, t: QbTorrent, now: Date, stats: SyncStats): Promise<boolean> { async function observe(d: AoDeps, seen: DownloadTaskRow, t: QbTorrent, now: Date, stats: SyncStats): Promise<boolean> {
// 行锁内按最新行合并:同步读到行之后入库事务可能已把 importing 推进到 imported,
// 用同步开始时的快照整行覆写会把状态写回 importing(丢失更新)
return d.ctx.db.transaction().execute(async (trx) => {
const row = await trx
.selectFrom("download_task")
.selectAll()
.where("id", "=", seen.id)
.forUpdate()
.executeTakeFirst();
if (!row || row.removed_at || row.status === "removed") return false;
return observeLocked(d, trx, row, t, now, stats);
});
}
async function observeLocked(
d: AoDeps,
trx: Exec,
row: DownloadTaskRow,
t: QbTorrent,
now: Date,
stats: SyncStats,
): Promise<boolean> {
const cur = { status: row.status, pauseReason: row.pause_reason }; const cur = { status: row.status, pauseReason: row.pause_reason };
const obs = mapQbState( const obs = mapQbState(
{ state: t.rawState, progress: t.progress, amountLeft: t.amountLeft }, { state: t.rawState, progress: t.progress, amountLeft: t.amountLeft },
@@ -691,80 +713,71 @@ async function observe(d: AoDeps, row: DownloadTaskRow, t: QbTorrent, now: Date,
} }
const becameComplete = merged.status === "completed" && !["completed", "importing", "imported"].includes(row.status); const becameComplete = merged.status === "completed" && !["completed", "importing", "imported"].includes(row.status);
const changed = merged.status !== row.status || merged.pauseReason !== row.pause_reason; const changed = merged.status !== row.status || merged.pauseReason !== row.pause_reason;
await d.ctx.db.transaction().execute(async (trx) => { await trx
await trx .updateTable("download_task")
.updateTable("download_task") .set({
.set({ status: merged.status,
status: merged.status, pause_reason: merged.status === "paused" ? merged.pauseReason : null,
pause_reason: merged.status === "paused" ? merged.pauseReason : null, raw_state: t.rawState,
raw_state: t.rawState, progress: Math.min(1, Math.max(0, t.progress)),
progress: Math.min(1, Math.max(0, t.progress)), size_bytes: sizeOf(t),
size_bytes: sizeOf(t), downloaded_bytes: downloaded,
downloaded_bytes: downloaded, remaining_bytes: Math.max(0, Math.round(t.amountLeft)),
remaining_bytes: Math.max(0, Math.round(t.amountLeft)), ratio: t.ratio,
ratio: t.ratio, seeding_time_sec: t.seedingTimeSec,
seeding_time_sec: t.seedingTimeSec, save_path: t.savePath,
save_path: t.savePath, content_path: t.contentPath,
content_path: t.contentPath, name: t.name,
name: t.name, tags: t.tags,
tags: t.tags, last_progress_at: lastProgressAt,
last_progress_at: lastProgressAt, no_progress_since: noProgressSince,
no_progress_since: noProgressSince, last_synced_at: now,
last_synced_at: now, ...(becameComplete ? { completed_at: now } : {}),
...(becameComplete ? { completed_at: now } : {}), })
}) .where("id", "=", row.id)
.where("id", "=", row.id) .execute();
.execute(); if (changed)
if (changed) await emitStatus(d, trx, row.id, row.status, merged.status, merged.status === "paused" ? merged.pauseReason : null);
await emitStatus( if (becameComplete) {
d, stats.completed.push(row.id);
trx, const plan = row.acquisition_plan_id
row.id, ? await trx
row.status, .selectFrom("acquisition_plan")
merged.status, .select(["id", "slot_id"])
merged.status === "paused" ? merged.pauseReason : null, .where("id", "=", row.acquisition_plan_id)
); .executeTakeFirst()
if (becameComplete) { : null;
stats.completed.push(row.id); if (plan) await applySlotEvent(d, trx, plan.slot_id, { type: "download_completed" }, { planId: plan.id });
const plan = row.acquisition_plan_id }
? await trx if (stalledNow) {
.selectFrom("acquisition_plan") // 无进展:只记证据 + 待处理,保留原任务,不换源(REQ §8.4.2)
.select(["id", "slot_id"]) await upsertDecision(d, trx, {
.where("id", "=", row.acquisition_plan_id) kind: "download_stalled",
.executeTakeFirst() title: `下载无进展:${t.name}`,
: null; summary: `已 ${Math.round((now.getTime() - (noProgressSince?.getTime() ?? now.getTime())) / 60000)} 分钟没有新增字节`,
if (plan) await applySlotEvent(d, trx, plan.slot_id, { type: "download_completed" }, { planId: plan.id }); subjectType: "download_task",
} subjectId: row.id,
if (stalledNow) { dedupeKey: `download_stalled:${row.id}`,
// 无进展:只记证据 + 待处理,保留原任务,不换源(REQ §8.4.2) evidence: {
await upsertDecision(d, trx, { rawState: t.rawState,
kind: "download_stalled", progress: t.progress,
title: `下载无进展:${t.name}`, downloadedBytes: downloaded,
summary: `已 ${Math.round((now.getTime() - (noProgressSince?.getTime() ?? now.getTime())) / 60000)} 分钟没有新增字节`, noProgressSince: noProgressSince?.toISOString(),
subjectType: "download_task", action: "record_evidence",
subjectId: row.id, },
dedupeKey: `download_stalled:${row.id}`, });
evidence: { }
rawState: t.rawState, if ((merged.status === "failed" || merged.status === "missing") && changed) {
progress: t.progress, await upsertDecision(d, trx, {
downloadedBytes: downloaded, kind: "download_failed",
noProgressSince: noProgressSince?.toISOString(), title: `下载异常:${t.name}`,
action: "record_evidence", summary: `qB 状态 ${t.rawState}`,
}, subjectType: "download_task",
}); subjectId: row.id,
} dedupeKey: `download_failed:${row.id}`,
if ((merged.status === "failed" || merged.status === "missing") && changed) { evidence: { rawState: t.rawState, progress: t.progress },
await upsertDecision(d, trx, { });
kind: "download_failed", }
title: `下载异常:${t.name}`,
summary: `qB 状态 ${t.rawState}`,
subjectType: "download_task",
subjectId: row.id,
dedupeKey: `download_failed:${row.id}`,
evidence: { rawState: t.rawState, progress: t.progress },
});
}
});
return changed; return changed;
} }
@@ -24,7 +24,6 @@ import {
type ExistingPolicy, type ExistingPolicy,
type ExistingTarget, type ExistingTarget,
findOtherVersions, findOtherVersions,
movieCompletion,
type NewBinding, type NewBinding,
type OrganizePlan, type OrganizePlan,
overrideFor, overrideFor,
@@ -34,7 +33,6 @@ import {
type QualityFacts, type QualityFacts,
resolveTargetDirectory, resolveTargetDirectory,
type SourceFile, type SourceFile,
unitCompletion,
} from "@umt/domain"; } from "@umt/domain";
import { organizationStatusFromFiles, organizePlanToFileResults, toDomainPathBinding } from "@umt/mapping"; import { organizationStatusFromFiles, organizePlanToFileResults, toDomainPathBinding } from "@umt/mapping";
import { withAttemptToken } from "@umt/tasks"; import { withAttemptToken } from "@umt/tasks";
@@ -45,6 +43,7 @@ import { applySlotEvent } from "./downloads.ts";
import { PathGuardError } from "./organize-guard.ts"; import { PathGuardError } from "./organize-guard.ts";
import { type FrozenTransfer, finalizeTransfer, planTransfer, type TransferSpec } from "./organize-transfer.ts"; import { type FrozenTransfer, finalizeTransfer, planTransfer, type TransferSpec } from "./organize-transfer.ts";
import { hnrSatisfied } from "./retention.ts"; import { hnrSatisfied } from "./retention.ts";
import { slotComplete as sharedSlotComplete } from "./subscriptions.ts";
// 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→ // 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→
// 资产/位置/路径绑定同事务登记 → Slot 推进 → Move 后移除 qB 任务记录(不删数据)。 // 资产/位置/路径绑定同事务登记 → Slot 推进 → Move 后移除 qB 任务记录(不删数据)。
@@ -1455,14 +1454,14 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
.where("id", "=", plan.slot_id) .where("id", "=", plan.slot_id)
.executeTakeFirst(); .executeTakeFirst();
if (slot && okAny) { if (slot && okAny) {
const complete = await slotComplete(trx, slot, frozen, verifiedAssets);
const old = slot.current_asset_id; const old = slot.current_asset_id;
// 完成判定与订阅完成(含历史)统一由 applySlotEvent(verify_passed) 按总集数账本重算,这里不再本地判定
await applySlotEvent( await applySlotEvent(
d, d,
trx, trx,
slot.id, slot.id,
{ type: "verify_passed", complete }, { type: "verify_passed", complete: false },
{ currentAssetId: verifiedAssets[0]!, reason: complete ? "verified" : "verified_partial" }, { currentAssetId: verifiedAssets[0]!, reason: "verified" },
); );
if (old && old !== verifiedAssets[0] && plan.purpose === "upgrade") { if (old && old !== verifiedAssets[0] && plan.purpose === "upgrade") {
// 升级:旧版本保留,处置只记待处理,不删 // 升级:旧版本保留,处置只记待处理,不删
@@ -1482,14 +1481,6 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
}, },
}); });
} }
if (complete && slot.subscription_id) {
await trx
.updateTable("subscription")
.set({ status: "completed", status_reason: "slot_satisfied" })
.where("id", "=", slot.subscription_id)
.where("mode", "=", "once")
.execute();
}
} else if (slot && !okAny) { } else if (slot && !okAny) {
await applySlotEvent(d, trx, slot.id, { type: "verify_failed" }, { reason: `organize:${status}` }); await applySlotEvent(d, trx, slot.id, { type: "verify_failed" }, { reason: `organize:${status}` });
} }
@@ -1505,7 +1496,7 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
.forUpdate() .forUpdate()
.executeTakeFirst(); .executeTakeFirst();
if (slot && !["reserved", "downloading", "verifying"].includes(slot.status)) { if (slot && !["reserved", "downloading", "verifying"].includes(slot.status)) {
const complete = await slotComplete(trx, slot, frozen, verifiedAssets); const { complete } = await sharedSlotComplete(trx, slot, frozen.mediaType, verifiedAssets);
await trx await trx
.updateTable("acquisition_slot") .updateTable("acquisition_slot")
.set({ .set({
@@ -1545,56 +1536,6 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
return { recordId: result.recordId, status: result.status, succeeded: result.succeeded, failed: result.failed }; return { recordId: result.recordId, status: result.status, succeeded: result.succeeded, failed: result.failed };
} }
async function slotComplete(
exec: Exec,
slot: { id: string; media_entity_id: string; content_unit_id: string | null; completion: unknown },
frozen: FrozenPlan,
newAssets: string[],
): Promise<boolean> {
const completion = (slot.completion ?? {}) as { kind?: string; cuts?: string[] };
if (frozen.mediaType === "movie") {
const assets = await exec
.selectFrom("media_asset")
.select(["id", "content_edition"])
.where("id", "in", newAssets)
.execute();
return movieCompletion(
completion.cuts as never,
assets.map((a) => ({
assetId: a.id,
verified: true,
...(a.content_edition ? { cut: a.content_edition as never } : {}),
})),
).complete;
}
// 剧集/综艺:按内容单元覆盖;单元未建档(总集数未知)时继续追踪,不声称完整
let q = exec
.selectFrom("content_unit")
.select(["id", "status"])
.where("media_entity_id", "=", slot.media_entity_id)
.where("kind", "=", "episode")
.where("content_type", "=", "main");
if (slot.content_unit_id) q = q.where("parent_unit_id", "=", slot.content_unit_id);
const units = await q.where("status", "!=", "retired").execute();
const required = units.map((u) => u.id);
if (required.length === 0) return false;
// 总集数只有在单元都已确认(元数据/人工)时才算已知;由发布标题或文件名临时建出的单元(provisional)
// 只说明"见过这些集",不说明"只有这些集":总集数未知时不判完整,订阅继续追踪(REQ §7.1.1)
const totalKnown = units.every((u) => u.status === "confirmed");
if (!totalKnown) return false;
const cov = await sql<{ asset_id: string; unit_id: string }>`
select c.media_asset_id as asset_id, c.content_unit_id as unit_id from asset_unit_coverage c
join media_asset a on a.id = c.media_asset_id
where a.media_entity_id = ${slot.media_entity_id} and a.status = 'active'`.execute(exec);
const byAsset = new Map<string, string[]>();
for (const r of cov.rows) byAsset.set(r.asset_id, [...(byAsset.get(r.asset_id) ?? []), r.unit_id]);
return unitCompletion(
required,
[...byAsset].map(([assetId, fileUnits]) => ({ assetId, verified: true, fileUnits })),
{ totalKnown },
).complete;
}
/** 按季号与集号区间找或建集单元(临时 provisional,挂在季单元下) */ /** 按季号与集号区间找或建集单元(临时 provisional,挂在季单元下) */
async function ensureEpisodeUnits( async function ensureEpisodeUnits(
exec: Exec, exec: Exec,
+3
View File
@@ -189,6 +189,7 @@ export async function finalize(
runId: string, runId: string,
res: DriveResult, res: DriveResult,
settled: AgentGoalReport | null, settled: AgentGoalReport | null,
opts: { summaryPrefix?: string } = {},
): Promise<{ status: AgentRunStatus; summary: string }> { ): Promise<{ status: AgentRunStatus; summary: string }> {
const { ctx } = env; const { ctx } = env;
const run = await ctx.db.selectFrom("agent_run").selectAll().where("id", "=", runId).executeTakeFirstOrThrow(); const run = await ctx.db.selectFrom("agent_run").selectAll().where("id", "=", runId).executeTakeFirstOrThrow();
@@ -213,6 +214,8 @@ export async function finalize(
} }
// 模型文本只作参考:附在业务核查结论之后,明确标注未经核查 // 模型文本只作参考:附在业务核查结论之后,明确标注未经核查
if (res.finalText && res.status !== "failed") summary += `\n模型汇报(未经核查):${res.finalText}`; if (res.finalText && res.status !== "failed") summary += `\n模型汇报(未经核查):${res.finalText}`;
// 系统核实结论(授权内恢复)与终态同一次写入:观察者看到终态时汇总已是最终文本
if (opts.summaryPrefix) summary = `${opts.summaryPrefix}\n${summary}`;
await ctx.db await ctx.db
.insertInto("artifact") .insertInto("artifact")
.values({ .values({
+4 -1
View File
@@ -90,8 +90,11 @@ export function registerAgentTasks(registry: TaskRegistry, env: () => AgentEnv)
maxRounds: rounds, maxRounds: rounds,
}); });
} }
const out = await t.step.run("finalize", () => finalize(e, runId, res, settled)); const out = await t.step.run("finalize", () =>
finalize(e, runId, res, settled, recovery ? { summaryPrefix: recovery.summary } : {}),
);
if (recovery) { if (recovery) {
// 已在 finalize 内带上核实结论;保留该步骤兼容旧 run 的步骤历史(已带前缀时幂等跳过)
const rec = recovery; const rec = recovery;
return t.step.run("recover-summary", () => annotateRunSummary(e.ctx, runId, rec)); return t.step.run("recover-summary", () => annotateRunSummary(e.ctx, runId, rec));
} }
+8 -1
View File
@@ -34,7 +34,14 @@ const worker = await startWorker({
logger, logger,
heartbeatMs: 60_000, heartbeatMs: 60_000,
bus, bus,
runtime: { ctx: booted.ctx, modules, setRelay: booted.outbox.setRelay, outboxIntervalMs: 100, concurrency: 4 }, runtime: {
ctx: booted.ctx,
modules,
setRelay: booted.outbox.setRelay,
outboxIntervalMs: 100,
concurrency: 4,
agentConcurrency: 2,
},
}); });
console.log(`worker ready pid=${Deno.pid}`); console.log(`worker ready pid=${Deno.pid}`);
Deno.addSignalListener("SIGTERM", async () => { Deno.addSignalListener("SIGTERM", async () => {
+2
View File
@@ -189,7 +189,9 @@ export async function createE2E(name: string, opts: E2EOptions = {}): Promise<E2
modules, modules,
setRelay: booted.outbox.setRelay, setRelay: booted.outbox.setRelay,
outboxIntervalMs: 100, outboxIntervalMs: 100,
// 并发显式固定,不随 WORKER_CONCURRENCY 等环境变量漂移
concurrency: 4, concurrency: 4,
agentConcurrency: 2,
}, },
}); });
e.worker = worker; e.worker = worker;
+33 -2
View File
@@ -1,9 +1,40 @@
import { defineConfig } from "vitest/config"; import { defineConfig } from "vitest/config";
// 真实 PG 的集成/e2e 测试共用开发库并行跑:建/删 schema 串行在一把咨询锁上(withSchemaLock), // 两组测试:
// 负载高时 beforeAll/afterAll 排队会超过缺省 10s,这里放宽钩子超时。 // unit —— 纯内存、无 PG,文件并行;
// pg —— 真实 PG 的集成/e2e(每文件独立业务 schema + 引擎 schema,真实 worker/后台循环),文件级串行,
// 并排在 unit 之后(groupOrder)。并发全量跑时多个 e2e 的 worker/轮询/迁移互相抢 CPU 与连接,
// 会把"入库→状态推进"这类时序拉长到超时,也会放大竞态;串行让每个文件拿到稳定的时序预算。
// 新测试文件缺省归入 pg(串行,安全);确认不碰 PG 的才加进 UNIT。
const UNIT = [
"test/api-registry.test.ts",
"test/auth.test.ts",
"test/health.test.ts",
"test/invoke-limits.test.ts",
"test/logger.test.ts",
"test/mapping-compat.test.ts",
"test/modules.test.ts",
"test/sse.test.ts",
"test/static.test.ts",
];
export default defineConfig({ export default defineConfig({
test: { test: {
// 建/删 schema 在一把咨询锁上排队(withSchemaLock),迁移 + 引擎 schema 在负载下可能超过缺省 10s
hookTimeout: 120_000, hookTimeout: 120_000,
teardownTimeout: 60_000,
projects: [
{ extends: true, test: { name: "unit", include: UNIT } },
{
extends: true,
test: {
name: "pg",
include: ["test/**/*.test.ts", "src/**/*.test.ts"],
exclude: UNIT,
fileParallelism: false,
sequence: { groupOrder: 1 },
},
},
],
}, },
}); });
Generated
+13
View File
@@ -1,6 +1,8 @@
{ {
"version": "5", "version": "5",
"specifiers": { "specifiers": {
"jsr:@std/internal@^1.0.14": "1.0.14",
"jsr:@std/path@1": "1.1.6",
"npm:@biomejs/biome@*": "2.5.14", "npm:@biomejs/biome@*": "2.5.14",
"npm:@biomejs/[email protected]": "2.5.14", "npm:@biomejs/[email protected]": "2.5.14",
"npm:@earendil-works/[email protected]": "[email protected]", "npm:@earendil-works/[email protected]": "[email protected]",
@@ -60,6 +62,17 @@
"npm:[email protected]": "4.6.5", "npm:[email protected]": "4.6.5",
"npm:zod@^4.3.6": "4.6.5" "npm:zod@^4.3.6": "4.6.5"
}, },
"jsr": {
"@std/[email protected]": {
"integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7"
},
"@std/[email protected]": {
"integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe",
"dependencies": [
"jsr:@std/internal"
]
}
},
"npm": { "npm": {
"@antfu/[email protected]": { "@antfu/[email protected]": {
"integrity": "sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA==", "integrity": "sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA==",
+33
View File
@@ -214,3 +214,36 @@
3. **按 03 清单把 IO 与 Agent 分成两个 worker 组**,给 verifyAndImport、runAgent 分配独立并发度,并加事件循环延迟探针。在性能基线(REQ §16.2)里记录文件数、挂载类型和 ci 耗时,防止 ci 从约 135 秒继续膨胀(可以考虑按包并行跑 `-j`)。 3. **按 03 清单把 IO 与 Agent 分成两个 worker 组**,给 verifyAndImport、runAgent 分配独立并发度,并加事件循环延迟探针。在性能基线(REQ §16.2)里记录文件数、挂载类型和 ci 耗时,防止 ci 从约 135 秒继续膨胀(可以考虑按包并行跑 `-j`)。
4. **e2e 矩阵扩到剧集和部分失败**:剧集追更(S5)、整季包部分已有(AT54)、部分文件入库后不清理(AT29)、多下载器同一 hash(AT73)。这些是 M2 综艺分段建模的前置样本。 4. **e2e 矩阵扩到剧集和部分失败**:剧集追更(S5)、整季包部分已有(AT54)、部分文件入库后不清理(AT29)、多下载器同一 hash(AT73)。这些是 M2 综艺分段建模的前置样本。
5. **把恢复和版本升级纳入 CI**:用 docker compose 跑"备份 → 清库 → 恢复 → 只读对账 → 映射比对"的夜间任务,再加一条 openworkflow 版本升级的回归测试,避免只靠人工演练记录。 5. **把恢复和版本升级纳入 CI**:用 docker compose 跑"备份 → 清库 → 恢复 → 只读对账 → 映射比对"的夜间任务,再加一条 openworkflow 版本升级的回归测试,避免只靠人工演练记录。
## W6 治稳记录(2026-09-28)
起点:W4/W5 全部合入后两次全量 `deno task ci` 各挂不同用例(run1 E8 两例:`download_task` 停在 `importing`、C 没被清理;run2 E1 等 qB 收种超时、E6 预算超限用例汇总文案不符)。
**区分方法**:10 个 e2e 文件逐个串行各跑 3 次,30/30 全绿;失败只在 44 个 server 测试文件并发全量时出现。结论是没有"稳定必现"的回归,但其中两处是并发下真实存在的产品竞态,另一处是负载导致的超时。
### 真实缺陷(并发暴露)与修法
| 现象 | 根因 | 修法 |
|---|---|---|
| E8:入库记录已 succeeded,`download_task` 仍是 `importing`;随后缓存清理不认它(期望 removed 实得 importing) | qB 同步 `observe` 先在事务外读 `download_task` 快照,再整行覆写 `status`;入库事务在两者之间把 `importing→imported` 提交后,同步按旧快照合并出 `importing` 写回(丢失更新)。负载高时窗口变大 | `services/downloads.ts observe`:事务内 `select … for update` 重读该行,按锁住的最新状态合并后写回(`mergeObservedDownloadState` 对 imported 保持不变) |
| E6 预算超限:run 已是 `budget_exceeded`,summary 还是 finalize 的原文,之后才被改写成"未恢复:调查超出预算…" | 授权内恢复的核实结论由 finalize 之后单独一步 `annotateRunSummary` 追加,终态与最终汇总分两次写,观察者(UI/SSE/测试)能读到中间态 | `finalize` 新增 `summaryPrefix`,runAgent 把恢复结论随终态同一次写入并推送;`recover-summary` 步骤保留(已带前缀时幂等跳过),兼容旧 run 的步骤历史 |
| (W5-C 遗留双写) | `organize.ts verifyAndImport` 仍用本地 `slotComplete` 判定并直接把 once 订阅置 completed,与 `applySlotEvent(verify_passed)` 按总集数账本的判定并存;总集数未知/订阅 pending 时两者会给出相反结论 | 删除 organize 本地 `slotComplete` 与订阅完成更新,完成与历史只由 `applySlotEvent` 钩子负责;手动整理指定 Slot 的分支改调 `services/subscriptions.ts slotComplete` |
测试期望未改。
### 不稳定项与治法
- E1 等 qB 收种 60s 超时:全量并发时 44 个文件各起真实 worker/outbox/后台循环抢 CPU 与连接,编排链被拉长。治法:`apps/server/vitest.config.ts` 分两个 project,`unit`(明确列出的纯内存文件)并行,`pg`(其余全部,含 e2e 与 PG 集成,新文件缺省归此)`fileParallelism:false` 且 `groupOrder:1` 排在 unit 之后;`packages/tasks` 的 PG 引擎测试同样文件级串行。
- worker 并发在 e2e 夹具与崩溃恢复子进程里显式配置(io 4 / agent 2),不受环境变量影响;钩子超时 120s、teardown 60s。
- 已有保障复核:每文件独立业务 schema + 引擎 schema(pid+随机后缀),建/删在咨询锁上串行;mock qB/TMDB/RSS/站点端口均为 0(随机);等待均走 `waitFor` 轮询 + 超时。E8/E9 里剩下的 1.5s 固定等待是"一段时间内不应发生"的反向断言,保留。
- 未引入任何重试。
### 3 次全量 ci
| 次 | 结果 | 耗时 | 其中 server vitest |
|---|---|---|---|
| 1 | 全绿(server 44 文件 / 259 用例) | 369s | 227s |
| 2 | 全绿 | 341s | 232s |
| 3 | 全绿 | 337s | 228s |
代价:server 测试从并发约 90s 变为串行约 230s,tasks 约 52s;换来的是时序预算可预期。后续如需提速,可按 e2e 与集成拆成两个串行组分进程跑。
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
// 引擎测试连真实 PG(每文件独立 schema)并起真实 worker 轮询/租约:文件级串行,避免并发抢占拉长租约与心跳时序。
export default defineConfig({
test: {
fileParallelism: false,
hookTimeout: 60_000,
},
});