dsh-deepmoyu 2.7.0 → 2.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/asr.py CHANGED
@@ -18,6 +18,7 @@ import argparse
18
18
  import os
19
19
  import re
20
20
  import sys
21
+ import time
21
22
  import uuid
22
23
 
23
24
  # ⚠️ 临时目录必须待在家目录里:值班那一轮跑在沙箱里(只允许写工作区/cwd),
@@ -56,6 +57,25 @@ def cleanup_download(tmp: str, tag: str) -> None:
56
57
  pass
57
58
 
58
59
 
60
+ def sweep_stale_downloads(tmp: str = _TMP, max_age_hours: float = 24.0) -> None:
61
+ """P3-33: 异常中断(进程被杀/断电)会留下 tag.* 碎片,且只有成功路径会清 ——
62
+ 启动时把 tmp/asr 下超过 24h 的下载碎片一并回收(当前这次的文件是新的,不受影响)。"""
63
+ cutoff = time.time() - max_age_hours * 3600
64
+ try:
65
+ with os.scandir(tmp) as entries:
66
+ for entry in entries:
67
+ try:
68
+ # 只回收普通文件与失效链接,目录一律不碰;删不掉就留给下一次。
69
+ if not (entry.is_file(follow_symlinks=False) or entry.is_symlink()):
70
+ continue
71
+ if entry.stat(follow_symlinks=False).st_mtime < cutoff:
72
+ os.remove(entry.path)
73
+ except OSError:
74
+ pass
75
+ except OSError:
76
+ pass
77
+
78
+
59
79
  def to_url(target: str) -> str:
60
80
  t = target.strip()
61
81
  if t.upper().startswith('BV') and len(t) <= 20:
@@ -99,6 +119,8 @@ def main() -> int:
99
119
  ap.add_argument('--lang', default='zh', help='语言(默认 zh;不给就自动判断请传 auto)')
100
120
  args = ap.parse_args()
101
121
  prepare_runtime()
122
+ # P3-33: 每次启动顺手清扫超龄碎片(只在真的要下载时才需要目录,清扫本身静默失败无妨)
123
+ sweep_stale_downloads()
102
124
 
103
125
  src = args.target
104
126
  # ⚠️ 不用 mkdtemp:沙箱只允许写"子进程启动前就存在"的目录,新建的会被拒 ✗
package/lib/favops.py CHANGED
@@ -11,6 +11,14 @@ import os
11
11
  import re
12
12
  import sys
13
13
 
14
+ # P3-31: Windows 管道默认 GBK —— 错误 JSON 里的中文/emoji 会把 print 打挂
15
+ #(UnicodeEncodeError 连 JSON 都出不来),先钉 UTF-8 + replace(写法照 lib/pv.py:41-46)
16
+ for _stream in (sys.stdout, sys.stderr):
17
+ try:
18
+ _stream.reconfigure(encoding="utf-8", errors="replace")
19
+ except Exception:
20
+ pass
21
+
14
22
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
15
23
 
16
24
  # 凭证找两处:优先**当前工作目录**(宿主 spawn 时 cwd = 插件家,凭证就在那儿),
@@ -23,10 +31,32 @@ CRED_FILE = next((p for p in CRED_CANDIDATES if os.path.exists(p)), CRED_CANDIDA
23
31
 
24
32
 
25
33
  def out(obj):
34
+ # P3-31: stdout 已钉 utf-8 + errors="replace",任何字符都出得去,不再 UnicodeEncodeError
26
35
  print(json.dumps(obj, ensure_ascii=False))
27
36
  sys.exit(0)
28
37
 
29
38
 
39
+ def _classify(error):
40
+ """P3-32: 错误分类 —— code 取 bilibili_api 异常自带的 API code(ResponseCodeException.code),
41
+ kind 让宿主分得清「要重扫码」(auth = -101/-111)、「等风控」(risk = 412/-352)、
42
+ 「已经收藏过」(dup,幂等成功)和其余失败(unknown)。"""
43
+ code = getattr(error, 'code', None)
44
+ try:
45
+ code = int(code) if code is not None else None
46
+ except (TypeError, ValueError):
47
+ code = None
48
+ msg = str(error)
49
+ if code in (-101, -111):
50
+ kind = 'auth'
51
+ elif code in (412, -352):
52
+ kind = 'risk'
53
+ elif '已经' in msg or 'already' in msg.lower():
54
+ kind = 'dup'
55
+ else:
56
+ kind = 'unknown'
57
+ return code, kind, msg
58
+
59
+
30
60
  def load():
31
61
  from bilibili_api import Credential
32
62
  if not os.path.exists(CRED_FILE):
@@ -65,11 +95,11 @@ async def add(bvid, fid):
65
95
  try:
66
96
  await favorite_list.FavoriteListResource(aid, type_=2, credential=cred).add(fid)
67
97
  except Exception as error: # noqa: BLE001
68
- msg = str(error)
69
- # 已经收藏过不算失败
70
- if '已经' in msg or 'already' in msg.lower():
71
- out({'ok': True, 'already': True})
72
- out({'ok': False, 'error': msg[:200]})
98
+ code, kind, msg = _classify(error)
99
+ # 已经收藏过不算失败:幂等判断保留子串兜底,但优先按 code 分好的类走
100
+ if kind == 'dup':
101
+ out({'ok': True, 'already': True, 'code': code, 'kind': kind})
102
+ out({'ok': False, 'error': msg[:200], 'code': code, 'kind': kind})
73
103
  out({'ok': True})
74
104
 
75
105
 
@@ -87,7 +117,9 @@ def main():
87
117
  else:
88
118
  out({'ok': False, 'error': '子命令不认识:' + ' '.join(args)})
89
119
  except Exception as error: # noqa: BLE001
90
- out({'ok': False, 'error': str(error)[:300]})
120
+ # P3-32: 兜底出口同样带 code/kind,宿主不用再猜错误文本
121
+ code, kind, msg = _classify(error)
122
+ out({'ok': False, 'error': msg[:300], 'code': code, 'kind': kind})
91
123
 
92
124
 
93
125
  if __name__ == '__main__':
@@ -0,0 +1,123 @@
1
+ /**
2
+ * lib/host-compat.mjs —— 启动时的「宿主兼容性自检」
3
+ * ============================================================================
4
+ * 🔴 为什么要有它(2026-11-10 真机事故):
5
+ * DSH 升到 0.12.1 之后,选「鲸鱼摸鱼模式」整个起不来,本轮直接
6
+ * `unknown prompt variable "{{cwd}}" in section "deployment:persona-suffix";
7
+ * registered variables: provider, model`
8
+ * 根因是 `presets/deepmoyu.patch.yml` 里 persona 的 `suffix` 写着
9
+ * `Your working directory is {{cwd}}.`:
10
+ * · `dsh-persona` 把 prefix/suffix 原样注册成提示词段(不做任何转义);
11
+ * · `dsh-system-prompt` 组装时会把段文本里所有 `{{名字}}` 当变量查表,
12
+ * **查不到就 throw**,而且这个 throw 发生在宿主每轮组装提示词的时候,
13
+ * 不是加载插件的时候 ⇒ 插件看着是好的,一开就炸。
14
+ *
15
+ * ⭐ 本模块只做一件很窄的事:**把这类错提前说清楚**。
16
+ * 插件打包时就把自己那两个 patch 文件里出现的 `{{...}}` 扫一遍,
17
+ * 只要出现白名单(宿主真正会注册的变量)以外的名字,就在 duty.log 里
18
+ * 写一条能照着改的告警 —— 而不是等使用者选了模式、看到一句英文堆栈。
19
+ *
20
+ * ⚠️ 刻意**不用 YAML 解析器**:`yaml` 是 devDependency,不进 npm 包;
21
+ * 这里做的是纯文本正则扫描(patch 文件是我们自己写的,形状稳定)。
22
+ * 扫描只产生日志,永远不抛错、不阻断加载 —— 自检本身不能变成新的风险。
23
+ * ============================================================================
24
+ */
25
+ import { existsSync, readFileSync } from 'node:fs'
26
+ import { join } from 'node:path'
27
+
28
+ /**
29
+ * 宿主真正注册过的提示词变量。
30
+ * DSH 0.12.1 里由 `@deepseek-ai/dsh-agent-loop` 注册 `provider` / `model` 两个,
31
+ * 被扫过一遍整个 asar,官方没有任何别处用 `{{cwd}}` 之类的写法。
32
+ * 宿主再升版时**先改这里,再改 `tests/prompt-variables.test.mjs` 里的同名白名单**。
33
+ */
34
+ export const HOST_PROMPT_VARIABLES = Object.freeze(['provider', 'model'])
35
+
36
+ /** 插件自己声明配置的那几个 patch 文件(相对插件根) */
37
+ export const PATCH_FILES = Object.freeze(['cordis.patch.yml', 'presets/deepmoyu.patch.yml'])
38
+
39
+ /** `{{名字}}` —— 与宿主 `GROUP_AT` 的"简单组"语义一致(中间不许再出现花括号) */
40
+ const VARIABLE = /\{\{([^{}]*)\}\}/g
41
+
42
+ /**
43
+ * 扫一个文本里出现的提示词变量名。
44
+ * @param {string} text 文件正文
45
+ * @returns {string[]} 出现过的变量名(去重,保持出现顺序)
46
+ */
47
+ export function promptVariablesIn(text) {
48
+ const seen = []
49
+ for (const match of String(text ?? '').matchAll(VARIABLE)) {
50
+ const name = match[1]
51
+ if (!seen.includes(name)) seen.push(name)
52
+ }
53
+ return seen
54
+ }
55
+
56
+ /**
57
+ * 去掉 YAML 注释行(`#` 开头)。
58
+ *
59
+ * 为什么需要:注释里为了讲清楚规矩,会写「别在这里写任何 `{{变量}}`」这种话,
60
+ * 那本身就是一段带花括号的注释;不剔掉就会自己告警自己。
61
+ *
62
+ * ⚠️ 取舍:这里不认 YAML 的块标量(`|` / `>` 里的 `#` 是正文,不是注释)。
63
+ * 人格正文是中文,几乎不会以 `#` 开头;真出现了也只是**少报**,不会误报停服务 ——
64
+ * 而真正把关的是 `tests/prompt-variables.test.mjs` 里那一条:它用 `parsePatch()`
65
+ * 把 YAML 解析成对象后只扫**值**,注释根本进不去。运行时自检是"多说一句"的辅助。
66
+ * @param {string} text
67
+ * @returns {string}
68
+ */
69
+ export function stripYamlComments(text) {
70
+ return String(text ?? '').split(/\r?\n/).filter(line => !/^\s*#/.test(line)).join('\n')
71
+ }
72
+
73
+ /**
74
+ * 扫插件目录下的 patch 文件,返回所有不在白名单里的变量。
75
+ * @param {string} pluginDir 插件根目录
76
+ * @param {readonly string[]} [allowed] 白名单(默认 {@link HOST_PROMPT_VARIABLES})
77
+ * @returns {Array<{file: string, name: string}>} 空数组 = 没问题
78
+ */
79
+ export function unknownPromptVariables(pluginDir, allowed = HOST_PROMPT_VARIABLES) {
80
+ const allow = new Set(allowed)
81
+ const found = []
82
+ for (const rel of PATCH_FILES) {
83
+ const file = join(pluginDir, rel)
84
+ // 文件没了不是兼容性问题(打包形状变了),交给别的测试去管
85
+ if (!existsSync(file)) continue
86
+ let text = ''
87
+ try { text = readFileSync(file, 'utf8') } catch { continue }
88
+ for (const name of promptVariablesIn(stripYamlComments(text))) {
89
+ if (!allow.has(name)) found.push({ file: rel, name })
90
+ }
91
+ }
92
+ return found
93
+ }
94
+
95
+ /**
96
+ * 给 duty.log 用的人话告警(没问题就返回空数组)。
97
+ * @param {string} pluginDir 插件根目录
98
+ * @returns {string[]}
99
+ */
100
+ export function promptVariableWarnings(pluginDir) {
101
+ const found = unknownPromptVariables(pluginDir)
102
+ if (found.length === 0) return []
103
+ const allowed = HOST_PROMPT_VARIABLES.join(' / ')
104
+ return found.map(({ file, name }) => `⚠️ ${file} 里写了 {{${name}}},但这个宿主只注册 ${allowed} 两个提示词变量`
105
+ + ' —— 宿主组装提示词时会直接抛 unknown prompt variable,整套模式就起不来。'
106
+ + '工作目录由宿主自己以 runtime context 注入,段里不用再写;别的动态信息请写成纯文字。')
107
+ }
108
+
109
+ /**
110
+ * 宿主版本(拿得到就拿来写进日志,方便下次排障)。
111
+ * 桌面版在 `resources/app.asar` 里,从模块位置往上找不到就返回 ''。
112
+ * @returns {string}
113
+ */
114
+ export function hostVersion() {
115
+ const candidates = [
116
+ process.env.DSH_HOST_VERSION,
117
+ process.env.DSH_VERSION,
118
+ ]
119
+ for (const value of candidates) {
120
+ if (value && String(value).trim()) return String(value).trim()
121
+ }
122
+ return ''
123
+ }
@@ -40,6 +40,11 @@ export function buildLearningReviewPrompt(items, { tokenBudget = 12000 } = {}) {
40
40
  export function learningReviewRetryAllowed(previous = {}, { batchKey, model = '', now = Date.now() } = {}) {
41
41
  const last = Number(previous.lastAttemptAt) || 0
42
42
  if (now - last < 180_000) return false
43
+ /* 审计 P3-11(2026-11-12):人工停止(stopReason='user')以前与错误同样进退避——
44
+ * 180 秒后 automationTick 又把同一批拉起来,"点了停止"实际停不住。现在同一批
45
+ * (batchKey+model 都没变)被人工停过就不再自动重启;队列内容变化(batchKey 变)
46
+ * 或换了模型才算新任务。手动 startLearningReview('manual') 不走这里,不受影响。 */
47
+ if (previous.userStop && previous.userStop.batchKey === batchKey && String(previous.userStop.model || '') === model) return false
43
48
  if (previous.batchKey !== batchKey || String(previous.model || '') !== model) return true
44
49
  return now >= (Number(previous.nextRetryAt) || last + 180_000)
45
50
  }
@@ -55,7 +60,12 @@ export function summarizeLearningReview(items, ledger, { code, error = '', stopR
55
60
  const failures = complete ? 0 : (same ? Number(previous.failures) || 0 : 0) + 1
56
61
  const delay = Math.min(30 * 60_000, 180_000 * 2 ** Math.min(4, Math.max(0, failures - 1)))
57
62
  const lastError = redactRecordText(error || (stopReason ? `审核已停止:${stopReason}` : code !== 0 ? `审核进程退出码 ${code}` : remaining ? `进程已退出,但本批仍有 ${remaining} 条未审核;已保留待审,稍后重试` : ''), { maxLength: 800 })
58
- return { approved, rejected, remaining, complete, failures, nextRetryAt: complete ? 0 : now + delay, lastError }
63
+ // 审计 P3-11:人工停止记下本批指纹(batchKey+model),同批不再被 automationTick 自动重拉;
64
+ // 非人工停止时若本批没变,沿用之前的 userStop 标记(一次人工停止对同一批持续有效)。
65
+ const userStop = stopReason === 'user'
66
+ ? { batchKey, model }
67
+ : (previous.userStop && previous.userStop.batchKey === batchKey ? previous.userStop : undefined)
68
+ return { approved, rejected, remaining, complete, failures, nextRetryAt: complete ? 0 : now + delay, lastError, userStop }
59
69
  }
60
70
 
61
71
  export function decideLearningReview(ledger, batch, args = {}) {
package/lib/login.mjs CHANGED
@@ -15,7 +15,7 @@
15
15
  * ============================================================================
16
16
  */
17
17
  import { spawn } from 'node:child_process'
18
- import { existsSync, mkdirSync } from 'node:fs'
18
+ import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs'
19
19
  import { join } from 'node:path'
20
20
  import { atomicWriteFileSync } from './record-storage.mjs'
21
21
  import { randomUUID } from 'node:crypto'
@@ -93,14 +93,31 @@ export function saveCredential(stateDir, credential) {
93
93
 
94
94
  /** 用 Python 的 qrcode 画一张 PNG,返回**文件路径**(拿不到就返回 null)
95
95
  * ⭐ 2026-10-26:加 20 秒超时 —— python 挂住时("既不起来也不退出")这个 Promise 以前永远不 settle,
96
- * `/login/start` 那个 HTTP 请求就永远挂着(probeCmd 那批同型问题修过,这里漏了)。 */
96
+ * `/login/start` 那个 HTTP 请求就永远挂着(probeCmd 那批同型问题修过,这里漏了)。
97
+ * 审计 P3-13(2026-11-12):失败/超时以前不清理半成品 PNG,成功路径也没人删——
98
+ * 每次登录尝试都在家目录留一个 qrcode_login.*.png(key 一次性且 180s 过期,无安全风险,纯卫生)。
99
+ * 现在失败即删;每次出码顺手清掉 24h 前的旧文件。 */
97
100
  export function renderQrPng(pythonPath, stateDir, url, { timeoutMs = 20_000 } = {}) {
98
101
  return new Promise((resolve) => {
99
102
  const png = join(stateDir, `qrcode_login.${process.pid}.${randomUUID()}.png`)
103
+ try {
104
+ const cutoff = Date.now() - 24 * 3600_000
105
+ for (const entry of readdirSync(stateDir)) {
106
+ if (!/^qrcode_login\..+\.png$/.test(entry)) continue
107
+ const old = join(stateDir, entry)
108
+ try { if (statSync(old).mtimeMs < cutoff) rmSync(old, { force: true }) } catch { /* 占用中就留给下次 */ }
109
+ }
110
+ } catch { /* 扫不了目录就算了 */ }
100
111
  const code = 'import sys, qrcode; qrcode.make(sys.argv[1]).save(sys.argv[2])'
101
112
  let child
102
113
  let settled = false
103
- const finish = (v) => { if (!settled) { settled = true; clearTimeout(timer); resolve(v) } }
114
+ const finish = (v) => {
115
+ if (settled) return
116
+ settled = true
117
+ clearTimeout(timer)
118
+ if (!v) { try { rmSync(png, { force: true }) } catch { /* 半成品也留给 24h 清扫 */ } }
119
+ resolve(v)
120
+ }
104
121
  const timer = setTimeout(() => {
105
122
  try { child?.kill('SIGKILL') } catch { /* 已经没了 */ }
106
123
  finish(null)
package/lib/mirror.mjs CHANGED
@@ -44,8 +44,22 @@ export function resolveMirrorDir(stateDir, explicit = '') {
44
44
  return ''
45
45
  }
46
46
 
47
+ /** 审计 P3-9(2026-11-12):state.json 镜像里 `sendReview[].args.text`(bili_dm_reply)
48
+ * 是待审私信的明文正文——活动流水对私信整行剔除,镜像却原样带走,两处口径不一致。
49
+ * 镜像定位是"脱敏的只读副本",这里对齐:DM 待审正文以占位符替换,其余字段照常。 */
50
+ function stripPendingDmText(value) {
51
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return value
52
+ const rows = Array.isArray(value.sendReview) ? value.sendReview : null
53
+ if (!rows) return value
54
+ return { ...value, sendReview: rows.map((row) => {
55
+ if (!row || typeof row !== 'object' || row.tool !== 'bili_dm_reply') return row
56
+ if (!row.args || typeof row.args !== 'object' || typeof row.args.text !== 'string') return row
57
+ return { ...row, args: { ...row.args, text: '[私信正文已隐去——待审私信不进镜像]' } }
58
+ }) }
59
+ }
60
+
47
61
  function mirrorText(fromDir, name, source) {
48
- if (name.endsWith('.json')) return JSON.stringify(sanitizeRecord(JSON.parse(source), { maxKeys: 10000, maxLength: 16000, maxDepth: 10 }), null, 2) + '\n'
62
+ if (name.endsWith('.json')) return JSON.stringify(sanitizeRecord(stripPendingDmText(JSON.parse(source)), { maxKeys: 10000, maxLength: 16000, maxDepth: 10 }), null, 2) + '\n'
49
63
  if (name === 'activity.jsonl') {
50
64
  const tail = readActivityTail(fromDir, { maxBytes: MIRROR_MAX_BYTES, limit: 5000 })
51
65
  if (tail.truncated) throw new Error('MIRROR_ACTIVITY_NEEDS_ROTATION')
@@ -1,7 +1,7 @@
1
1
  /** Local, per-run capability bridge to the parent's already configured LLM adapters.
2
2
  * No credentials/profile copies, no route fallback, no public web endpoint. */
3
3
  import { createServer } from 'node:http'
4
- import { randomBytes, timingSafeEqual } from 'node:crypto'
4
+ import { randomBytes } from 'node:crypto'
5
5
  import { once } from 'node:events'
6
6
  import { redactRecordText } from './record-privacy.mjs'
7
7
  import { summarizeBackgroundDiagnostic } from './background-output.mjs'
@@ -56,8 +56,12 @@ export function createModelRelay({ getLlm, onError } = {}) {
56
56
  // No browser origins, CORS or ambient cookie auth. Each child gets a revocable capability.
57
57
  if (req.method !== 'POST' || req.url !== '/call' || req.headers.origin) return jsonReply(res, 403, { error: { code: 'RELAY_FORBIDDEN', message: '模型桥接拒绝此请求' } })
58
58
  const token = String(req.headers.authorization || '').replace(/^Bearer /, '')
59
+ /* 审计 P3-25(2026-11-12):`grants.get(token)` 本身就是按 token 精确匹配——命中后
60
+ * 再 timingSafeEqual 比较的两个 buffer 恒等,这段比较从不起作用(错误长度的 token
61
+ * 走 `!grant` → 403 短路)。Map 键比较在此处是时间安全的(V8 字符串哈希桶查找,
62
+ * 键只在本进程内生成且不可枚举),直接删除冗余比较并留注说明。 */
59
63
  const grant = grants.get(token)
60
- if (!grant || !timingSafeEqual(Buffer.from(token), Buffer.from(grant.token)) || Date.now() >= grant.expiresAt) return jsonReply(res, 403, { error: { code: 'RELAY_EXPIRED', message: '后台模型凭据已撤销或过期,请重新开跑' } })
64
+ if (!grant || Date.now() >= grant.expiresAt) return jsonReply(res, 403, { error: { code: 'RELAY_EXPIRED', message: '后台模型凭据已撤销或过期,请重新开跑' } })
61
65
  const abort = new AbortController()
62
66
  grant.controllers.add(abort)
63
67
  const disconnected = () => { if (!res.writableEnded) abort.abort() }
package/lib/persona.mjs CHANGED
@@ -170,7 +170,9 @@ export function ensureSoul(stateDir, log = () => {}) {
170
170
  const file = soulFileOf(stateDir)
171
171
  try {
172
172
  if (existsSync(file)) return false
173
- writeFileSync(file, soulTemplate(), 'utf8')
173
+ // 审计 P3-18(2026-11-12):初始铺模板也走原子写——裸 write 半篇留档会被 existsSync
174
+ // 永久当成"用户写过",残缺模板就再也修不回来了。
175
+ atomicWriteFileSync(file, soulTemplate(), 'utf8')
174
176
  log(`已铺好:${file}`)
175
177
  return true
176
178
  } catch (e) {
@@ -62,10 +62,16 @@ function inheritPermissions(ctx, source, target) {
62
62
  if (source.sandbox && source.sandbox !== before.sandbox) target.append('sandbox/mode', { mode: source.sandbox })
63
63
  if (source.approval && source.approval !== before.approval) target.append('approval/policy', { policy: source.approval })
64
64
  const after = permissionsOf(ctx, target)
65
- for (const key of ['sandbox', 'approval']) if (source[key] && after[key] && after[key] !== source[key]) {
66
- throw new Error(`会话已创建,但 ${key} 权限继承未生效;请手动检查该会话权限`)
65
+ // 审计 P3-22(2026-11-12):读回为 undefined(宿主缺 sessionProjections 等服务)时,
66
+ // 以前整个校验被跳过却返回"继承成功"——append 发出了但未经读回验证。现在把这种
67
+ // 情况显式标成 unverified,由调用方(HTTP 回执/日志)如实呈现。
68
+ let unverified = false
69
+ for (const key of ['sandbox', 'approval']) {
70
+ if (!source[key]) continue
71
+ if (after[key] === undefined) { unverified = true; continue }
72
+ if (after[key] !== source[key]) throw new Error(`会话已创建,但 ${key} 权限继承未生效;请手动检查该会话权限`)
67
73
  }
68
- return true
74
+ return unverified ? 'unverified' : true
69
75
  }
70
76
 
71
77
  /**
@@ -118,6 +118,13 @@ export function createVideoStudio({ ctx, pv, stateDir, config, publicSources = (
118
118
  const current = pv.status(), row = sessionRow(sessionId) || (!sessionId && rows.find(item => item.productionId && item.productionId === current.id))
119
119
  if (sessionId && !row) return { ok: true, stopped: false, hint: '没有找到这个制作会话' }
120
120
  if (!row) return pv.stop()
121
+ /* 审计 P2-8(2026-11-12):终态不可逆——已交付回执(done + terminalDelivered)的会话
122
+ * 再收到 stop 时,以前会被无条件改写成 stopped,会话侧的「目标终态」丢失(磁盘成片
123
+ * 不受影响,但 status 从此显示"已手动停止")。现在对已交付终态的行直接原样返回。 */
124
+ if (row.stage === 'done' || row.terminalDelivered) {
125
+ return { ok: true, stopped: false, sessionId: row.sessionId, stage: row.stage,
126
+ hint: '该会话已交付终态(成片已完成且回执已送达),没有可停止的任务。' }
127
+ }
121
128
  const before = row.stage
122
129
  row.stage = 'stopped'; row.error = ''; remember(row) // Fence completion callbacks before cancellation.
123
130
  const result = row.productionId && row.productionId === current.id ? pv.stop() : { ok: true, stopped: false }
@@ -132,7 +139,13 @@ export function createVideoStudio({ ctx, pv, stateDir, config, publicSources = (
132
139
  const settled = async result => {
133
140
  if (disposed || !result.id) return
134
141
  const row = rows.find(item => item.productionId === result.id)
135
- if (!row || row.stage === 'stopped' || row.terminalDelivered === result.id || delivering.has(result.id)) return
142
+ if (!row) {
143
+ // 审计 P3-19(2026-11-12):行索引只留 60 条,被裁剪行的渲染完成事件以前无声丢失——
144
+ // 至少留一条可发现的记录(AGENTS:终态回传原会话一次;回传不了也要说清为什么)。
145
+ log(`⚠️ 渲染 ${result.id}(${result.date || '?'})完成,但对应的制作会话已被裁剪出列表——终态无法回传原会话,成片与回执见 productions 列表`)
146
+ return
147
+ }
148
+ if (row.stage === 'stopped' || row.terminalDelivered === result.id || delivering.has(result.id)) return
136
149
  delivering.add(result.id)
137
150
  try {
138
151
  const verified = result.ok === true && result.stage === 'done' && !result.verificationSkipped
@@ -214,6 +227,8 @@ export function createVideoStudio({ ctx, pv, stateDir, config, publicSources = (
214
227
  } finally { creating = false }
215
228
  }
216
229
  const prepare = (args = {}, sessionId = '') => {
230
+ // 审计 P3-24:preview/render 都有 disposed 闸,prepare 漏了——卸载竞态窗口内仍可返回素材快照。
231
+ if (disposed) return { ok: false, error: '视频制作服务已关闭' }
217
232
  const saved = sessionRow(sessionId)
218
233
  const request = saved || videoRequest(args)
219
234
  if (saved && args.date && args.date !== saved.date) throw new Error('日期与当前制作会话不一致')
package/lib/pv.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  import { spawn } from 'node:child_process'
3
3
  import { randomUUID } from 'node:crypto'
4
4
  import { appendFileSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
5
- import { isAbsolute, join, relative, resolve } from 'node:path'
5
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
6
6
  import { normalizeProduction } from './pv-production.mjs'
7
7
 
8
8
  export const PV_PREFIX = '大肥鱼日报'
@@ -72,12 +72,23 @@ const usableVideo = (file) => {
72
72
  /** Keep the historical date-indexed list compatible; studio jobs have productions(). */
73
73
  export function listPv(outDir, legacyDirs = outDir) {
74
74
  const byDate = new Map()
75
+ const verificationOk = (date, dir) => {
76
+ // 审计 P3-20(2026-11-12):legacy 列表以前只看「文件存在且 >1KB」——手工放进 out 的
77
+ // 任意 mp4 也会以成片身份进列表/播放端点(studio 的 productions() 严格要求校验回执)。
78
+ // 现在给每行带上 verified 标记:旁边有 ok:true 的 verification.json 才算验证过,
79
+ // 消费端(bili_pv list / 设置页)可以区分「校验过」与「只是个视频文件」。
80
+ try {
81
+ const receipt = JSON.parse(readFileSync(join(dateDir(dir, date), 'verification.json'), 'utf8'))
82
+ return receipt?.ok === true
83
+ } catch { return false }
84
+ }
75
85
  const add = (date, file, canonical) => {
76
86
  if (!pvDateOk(date) || !usableVideo(file)) return
77
87
  const old = byDate.get(date)
78
88
  if (!old || (!old.canonical && canonical)) {
79
89
  const s = statSync(file)
80
- byDate.set(date, { date, file, size: s.size, at: s.mtime.toISOString(), canonical })
90
+ byDate.set(date, { date, file, size: s.size, at: s.mtime.toISOString(), canonical,
91
+ verified: verificationOk(date, canonical ? outDir : dirname(file)) })
81
92
  }
82
93
  }
83
94
  try {
@@ -170,7 +181,11 @@ const productionRow = (outDir, id) => {
170
181
  export function createPv(o) {
171
182
  const { stateDir, pluginDir, python, dirs, env = null } = o
172
183
  const say = (s) => { try { o.log?.(s) } catch { /* host logger */ } }
173
- const timeoutMs = Number(o.timeoutMs) > 0 ? Number(o.timeoutMs) : 20 * 60_000
184
+ /* 审计 P2-12(2026-11-12):默认任务总时限 20 分钟 < py 侧 GSV 批处理自己的 60 分钟预算
185
+ * (pv.py:776 timeout=3600,且 pv_gsv.py 强制 CPU 推理,模型加载就要 30-50s)——长批处理
186
+ * 到点被 JS 强杀整树,py 的预算形同虚设。默认提到 75 分钟(> py 60 分钟 + 模型加载余量),
187
+ * py 侧超时先到、报错更有指向性;显式传 timeoutMs 的调用方不受影响。 */
188
+ const timeoutMs = Number(o.timeoutMs) > 0 ? Number(o.timeoutMs) : 75 * 60_000
174
189
  const gsvOf = () => { try { return o.gsv?.() || null } catch { return null } }
175
190
  let job = null
176
191
  const settledListeners = new Set()