dsh-mcmp 2.1.4 → 2.2.0

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.
Files changed (3) hide show
  1. package/README.md +3 -1
  2. package/lib/index.js +131 -25
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -19,6 +19,8 @@ DeepSeek Harness **持久化 Cordis 插件**:面向全国大学生数学建模
19
19
  - **成果全落盘**:每步迭代完整追加保存(含质疑清单),旧内容绝不覆盖;代码保留全部历史版本;全新运行时旧目录自动归档为 `数学建模流水线_历史归档_*`
20
20
  - **实时进度面板**:独立浮动窗口(可拖拽/最小化/关闭),显示百分比进度条、当前轮/步骤/迭代、8 步骤打点、日志、产出文件,支持一键中止、重置(全新开始)
21
21
  - **断点续跑与重置**:中断/关闭窗口不丢进度,再次 `/loopbegin` 自动续跑;`/loopreset` 或面板「重置(全新开始)」清空续跑记录,下次从头跑
22
+ - **迭代失败自动重试**:单个子任务因模型限流/过载(429、5xx)等瞬时故障失败时,自动退避重试(默认 15s/45s,最多 2 次),吸收抖动、避免"连续 3 败提前终止";确实持续失败才终止,并提示排查方向
23
+ - **`--from` 文件自动补全**:`--from 文件名` 支持部分匹配,自动查找工作区根目录下的 `.md` 文档(前缀优先、包含次之;唯一命中即采用,歧义列出候选);只写 `--from` 会列出全部 MD 文档供选择——不用再复制粘贴完整文件名
22
24
  - **原生工作流卡片**:聊天区实时显示每个子智能体的运行状态(按阶段分组)
23
25
 
24
26
  ## 八大步骤(每轮 19 次迭代)
@@ -42,7 +44,7 @@ DeepSeek Harness **持久化 Cordis 插件**:面向全国大学生数学建模
42
44
  2. **触发插件**:发送一条以 `/loopbegin` 开头的消息:
43
45
  - `/loopbegin` —— 默认 1 轮
44
46
  - `/loopbegin --round=3` —— 3 轮外循环(上限 10)
45
- - `/loopbegin --from 题目.txt` —— 从文件读取赛题(支持含空格/中文的文件名,如 `--from 2024 年高教社杯全国大学生数学建模竞赛题目.md`,也可用 `--from=文件` 形式)
47
+ - `/loopbegin --from 题目.txt` —— 从文件读取赛题。**支持部分匹配自动补全**:只写 `--from` 会列出根目录全部 MD 文档;`--from 2024` 会自动匹配到 `2024 年高教社杯全国大学生数学建模竞赛题目.md`(唯一命中即用,歧义列出候选)
46
48
  3. **查看进度**:右下角浮动面板(百分比、步骤打点、日志、文件)+ 聊天区工作流卡片。
47
49
 
48
50
  辅助命令:`/loopstatus`(查看进度)、`/loopabort`(中止,已产出成果保留)、`/loopreset`(清空状态与断点续跑记录,下次 `/loopbegin` 从头全新开始;等价于面板「重置(全新开始)」按钮)。
package/lib/index.js CHANGED
@@ -29,12 +29,38 @@ export function apply(ctx) {
29
29
  const subagents = ctx.get('subagents')
30
30
  const systemPrompt = ctx.get('systemPrompt')
31
31
  const webServer = ctx.get('webServer')
32
- console.log('dsh-mcmp: services available -> commands=' + !!commands + ' fs=' + !!fs + ' agents=' + !!agents + ' subagents=' + !!subagents + ' systemPrompt=' + !!systemPrompt + ' webServer=' + !!webServer)
32
+ const timerSvc = ctx.get('timer')
33
+ console.log('dsh-mcmp: services available -> commands=' + !!commands + ' fs=' + !!fs + ' agents=' + !!agents + ' subagents=' + !!subagents + ' systemPrompt=' + !!systemPrompt + ' webServer=' + !!webServer + ' timer=' + !!timerSvc)
33
34
 
34
35
  const FLOW_NAME = '数学建模论文流水线v2'
35
36
  // 全流水线缺陷台账:跨步骤错误登记、裁决与向上修复的唯一权威文件
36
37
  const LEDGER = '00_缺陷与裁决台账.md'
37
38
 
39
+ // 迭代级重试:一次迭代失败(如模型 429/5xx 瞬时抖动)后自动退避重试,
40
+ // 避免"连续 3 个子任务失败"被瞬时故障触发。重试间隔可用 --retry-ms=N 覆盖(测试/调优用)。
41
+ const DEFAULT_RETRY_DELAYS = [15000, 45000]
42
+ const MAX_ITER_RETRY = 2
43
+
44
+ function sleepAbortable(ms, signal) {
45
+ return new Promise((resolve) => {
46
+ let settled = false
47
+ let timerDispose = null
48
+ const finish = () => {
49
+ if (settled) return
50
+ settled = true
51
+ if (timerDispose) { try { timerDispose() } catch (err) { /* ignore */ } }
52
+ resolve()
53
+ }
54
+ if (signal && signal.aborted) { resolve(); return }
55
+ if (signal && typeof signal.addEventListener === 'function') signal.addEventListener('abort', finish, { once: true })
56
+ if (timerSvc && typeof timerSvc.timeout === 'function') {
57
+ try { timerDispose = timerSvc.timeout(finish, ms) } catch (err) { finish() }
58
+ } else {
59
+ finish() // 无 timer 服务时不做等待,直接重试
60
+ }
61
+ })
62
+ }
63
+
38
64
  // ---------- 识图能力探测(宿主侧,仅供参考;以子智能体自检为准) ----------
39
65
  function detectVision() {
40
66
  // 1) 宿主服务:常见的识图服务键
@@ -113,6 +139,7 @@ export function apply(ctx) {
113
139
  let state = freshState()
114
140
  let active = null
115
141
  let lastSession = null // 最近一次运行所属会话,供「清空状态」追加重置标记
142
+ let starting = false // 启动锁:async startPipeline 中等待目录扫描时防止并发启动
116
143
  const recording = new Map()
117
144
 
118
145
  function fmtTime(ts) {
@@ -289,6 +316,67 @@ export function apply(ctx) {
289
316
  return filtered.trim()
290
317
  }
291
318
 
319
+ // ---------- --from 的文件补全:列出/匹配工作区根目录的 .md 文档 ----------
320
+ async function listMdFiles(dir) {
321
+ if (!fs || !dir) return []
322
+ try {
323
+ const root = await fs.resolve(dir)
324
+ const entries = await fs.listDir(root)
325
+ return entries
326
+ .filter((e) => e && e.type === 'file' && /\.md$/i.test(e.name || ''))
327
+ .map((e) => e.name)
328
+ .sort()
329
+ } catch (err) { /* 目录不存在/不可读时返回空 */ }
330
+ return []
331
+ }
332
+
333
+ function pickMdMatch(names, partial) {
334
+ const p = (partial || '').toLowerCase()
335
+ const exact = names.filter((n) => n.toLowerCase() === p)
336
+ if (exact.length === 1) return { kind: 'match', name: exact[0] }
337
+ const starts = names.filter((n) => n.toLowerCase().startsWith(p))
338
+ if (starts.length === 1) return { kind: 'match', name: starts[0] }
339
+ if (starts.length > 1) return { kind: 'ambiguous', names: starts }
340
+ const contains = names.filter((n) => n.toLowerCase().indexOf(p) >= 0)
341
+ if (contains.length === 1) return { kind: 'match', name: contains[0] }
342
+ if (contains.length > 1) return { kind: 'ambiguous', names: contains }
343
+ return { kind: 'none' }
344
+ }
345
+
346
+ async function resolveFromPath(cwd, raw) {
347
+ // 返回 { kind:'ok', path } | { kind:'error', text }
348
+ let p = (raw || '').trim().replace(/^["']|["']$/g, '')
349
+ const cwdNorm = cwd.replace(/[\\/]+$/, '')
350
+ if (p.length === 0) {
351
+ const names = await listMdFiles(cwd)
352
+ if (names.length === 0) return { kind: 'error', text: '--from 未指定文件,且工作区根目录下没有 .md 文档。' }
353
+ return { kind: 'error', text: '--from 未指定文件。工作区根目录下的 MD 文档:\n' + names.map((n) => ' - ' + n).join('\n') + '\n请用 --from 文件名(支持部分匹配,如 --from 2024)重新指定。' }
354
+ }
355
+ // 绝对路径
356
+ if (/^[A-Za-z]:[\\/]/.test(p) || p.indexOf('/') === 0 || p.indexOf('\\') === 0) {
357
+ return { kind: 'ok', path: p }
358
+ }
359
+ // 含路径分隔符的相对路径:直接按路径解析并验证存在
360
+ if (p.indexOf('/') >= 0 || p.indexOf('\\') >= 0) {
361
+ const full = cwdNorm + '/' + p
362
+ try {
363
+ const t = await fs.resolve(full)
364
+ const st = await fs.stat(t)
365
+ if (st && st.type === 'file') return { kind: 'ok', path: full }
366
+ return { kind: 'error', text: '路径不是文件: ' + p }
367
+ } catch (err) { /* fallthrough 到模糊匹配 */ }
368
+ }
369
+ // 模糊匹配文件名
370
+ const names = await listMdFiles(cwd)
371
+ const pick = pickMdMatch(names, p)
372
+ if (pick.kind === 'match') return { kind: 'ok', path: cwdNorm + '/' + pick.name }
373
+ if (pick.kind === 'ambiguous') {
374
+ return { kind: 'error', text: '--from "' + p + '" 匹配到多个文档,请补充关键字:\n' + pick.names.map((n) => ' - ' + n).join('\n') }
375
+ }
376
+ const hint = names.length > 0 ? '\n工作区根目录现有 MD 文档:\n' + names.map((n) => ' - ' + n).join('\n') : ''
377
+ return { kind: 'error', text: '未找到匹配 "' + p + '" 的 MD 文档。' + hint }
378
+ }
379
+
292
380
  function buildPrompt(round, si, ii, s, it, args) {
293
381
  const p = []
294
382
  p.push('你正在参与「全国大学生数学建模竞赛论文」的多轮自动化撰写流水线,担任本轮「' + s.name + ' · ' + it.name + '」的执行专家。你的目标不是"写完",而是产出**可直接放进竞赛论文、经得起评审质疑**的内容。')
@@ -476,10 +564,23 @@ export function apply(ctx) {
476
564
  let started = false
477
565
  let outcome = 'failed'
478
566
  let note = ''
479
- const res = await runChild(provider, session, agent, controller, label, buildPrompt(r, f.s, f.i, s, it, args), (child) => {
480
- started = true
481
- appendRec(runId, session, 'tool-workflow/agent-start', { runId, seq: g + 1, label, phase: s.phase, childId: String(child.id) })
482
- })
567
+ // 迭代级重试:失败(如模型 429/5xx 瞬时抖动)后自动退避重试,吸收瞬时故障
568
+ const retryDelays = Array.isArray(args.retryDelays) && args.retryDelays.length ? args.retryDelays : DEFAULT_RETRY_DELAYS
569
+ let res = null
570
+ for (let attempt = 0; ; attempt++) {
571
+ res = await runChild(provider, session, agent, controller, label, buildPrompt(r, f.s, f.i, s, it, args), (child) => {
572
+ if (!started) {
573
+ started = true
574
+ appendRec(runId, session, 'tool-workflow/agent-start', { runId, seq: g + 1, label, phase: s.phase, childId: String(child.id) })
575
+ }
576
+ })
577
+ if (res.outcome !== 'failed' || attempt >= MAX_ITER_RETRY) break
578
+ const delayMs = retryDelays[Math.min(attempt, retryDelays.length - 1)] || 0
579
+ pushLog('迭代失败,将在 ' + Math.round(delayMs / 1000) + 's 后重试(第 ' + (attempt + 1) + '/' + MAX_ITER_RETRY + ' 次): ' + label)
580
+ await sleepAbortable(delayMs, controller.signal)
581
+ if (controller.signal.aborted) { cancelled = true; break }
582
+ }
583
+ if (cancelled) break
483
584
  outcome = res.outcome
484
585
  note = (res.text || '').trim().slice(0, 300)
485
586
  if (res.err) note = '执行异常: ' + String((res.err && res.err.message) || res.err).slice(0, 200)
@@ -506,7 +607,7 @@ export function apply(ctx) {
506
607
  if (controller.signal.aborted) { cancelled = true; break }
507
608
  if (failStreak >= 3) {
508
609
  stopReason = 'error'
509
- errorMsg = '连续 3 个子任务失败,流水线提前终止(已有成果全部保留,可重新 /loopbegin 续跑)'
610
+ errorMsg = '连续 3 个子任务失败(每个失败迭代已含自动重试),流水线提前终止。常见原因:模型服务限流(429)/账户余额不足(402)/上下文过长/工作区文件问题;请排查后重新 /loopbegin 续跑,或 /loopreset 后重来。已有成果全部保留。'
510
611
  break
511
612
  }
512
613
  }
@@ -540,12 +641,13 @@ export function apply(ctx) {
540
641
  if (active && active.runId === runId) active = null
541
642
  }
542
643
 
543
- // ---------- 共享的流水线启动逻辑(同步执行,永不阻塞命令通道) ----------
544
- function startPipeline(agent, rawInput) {
644
+ // ---------- 共享的流水线启动逻辑(async:--from 需要异步扫描目录;启动锁防并发) ----------
645
+ async function startPipeline(agent, rawInput) {
646
+ if (state.status === 'running' || starting) {
647
+ return { kind: 'error', text: '已有流水线正在运行(当前进度 ' + (state.total > 0 ? Math.floor((state.done * 100) / state.total) : 0) + '%)。可在浮动面板点「中止」或运行 /loopabort 后再启动新流水线。' }
648
+ }
649
+ starting = true
545
650
  try {
546
- if (state.status === 'running') {
547
- return { kind: 'error', text: '已有流水线正在运行(当前进度 ' + (state.total > 0 ? Math.floor((state.done * 100) / state.total) : 0) + '%)。可在浮动面板点「中止」或运行 /loopabort 后再启动新流水线。' }
548
- }
549
651
  if (!subagents) return { kind: 'error', text: '子智能体服务不可用(当前部署未提供),无法启动流水线。' }
550
652
  const session = agent && agent.session
551
653
  const cwd = session && session.header ? session.header.cwd : undefined
@@ -560,20 +662,21 @@ export function apply(ctx) {
560
662
  if (!Number.isSafeInteger(rounds) || rounds < 1) rounds = 1
561
663
  if (rounds > 10) return { kind: 'error', text: '--round 最大为 10。每轮 19 次迭代、每次迭代启动一个专家子智能体,轮数过大会非常耗时。' }
562
664
  const fresh = /(?:^|\s)--fresh\b/i.test(raw)
563
- // --from 路径:允许含空格,遇到下一个 -- 标志或行尾即结束,避免吞掉后面的 --round/--fresh
564
- const mFrom = /(?:^|\s)--from(?:=)?\s*([^\r\n]+?)(?=\s+--|\r?$)/i.exec(raw)
665
+ // 迭代失败重试间隔(毫秒),默认 [15s, 45s];--retry-ms=N 可覆盖(测试/调优)
666
+ const mRetry = /(?:^|\s)--retry-ms\s*[=:]?\s*(\d+)/i.exec(raw)
667
+ const retryDelays = mRetry ? [Math.max(1, parseInt(mRetry[1], 10)), Math.max(1, parseInt(mRetry[1], 10))] : DEFAULT_RETRY_DELAYS
668
+ // --from:允许只写 --from(列出根目录 MD 文档)或部分文件名(自动匹配补全)
669
+ const hasFrom = /(?:^|\s)--from\b/i.test(raw)
670
+ const mFrom = hasFrom ? /(?:^|\s)--from(?:=)?\s*([^\r\n]+?)(?=\s+--|\r?$)/i.exec(raw) : null
565
671
  let problem = null
566
- if (mFrom) {
567
- let p = mFrom[1].trim().replace(/^["']|["']$/g, '')
568
- if (p.length === 0) return { kind: 'error', text: '--from 需要指定赛题文件路径。' }
569
- const abs = /^[A-Za-z]:[\\/]/.test(p) || p.indexOf('/') === 0 || p.indexOf('\\') === 0
570
- ? p
571
- : cwd.replace(/[\\/]+$/, '') + '/' + p
572
- problem = { kind: 'file', path: abs }
672
+ if (hasFrom) {
673
+ const resolved = await resolveFromPath(cwd, mFrom ? mFrom[1] : '')
674
+ if (resolved.kind === 'error') return { kind: 'error', text: resolved.text }
675
+ problem = { kind: 'file', path: resolved.path }
573
676
  } else {
574
677
  const text = extractProblem(session)
575
678
  if (text.trim().length < 40) {
576
- return { kind: 'error', text: '未能从对话中提取到赛题。请先把完整赛题粘贴到对话框并发送,再发送以 /loopbegin 开头的消息;或使用 /loopbegin --from 题目.txt 指定赛题文件。' }
679
+ return { kind: 'error', text: '未能从对话中提取到赛题。请先把完整赛题粘贴到对话框并发送,再发送以 /loopbegin 开头的消息;或使用 /loopbegin --from 文件名(支持部分匹配自动查找根目录 .md 文档)。' }
577
680
  }
578
681
  problem = { kind: 'text', text: text.length > 6000 ? text.slice(0, 6000) + '\n…(原文过长,已截断)' : text }
579
682
  }
@@ -581,7 +684,7 @@ export function apply(ctx) {
581
684
  const problemPath = problem.kind === 'file' ? problem.path : wsDir + '/00_赛题原文.md'
582
685
  const title = problem.kind === 'text' ? problem.text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0)[0] : problem.path
583
686
  const vision = detectVision()
584
- const args = { rounds, wsDir, problemPath, problem, title: title ? title.slice(0, 40) : '赛题', vision }
687
+ const args = { rounds, wsDir, problemPath, problem, title: title ? title.slice(0, 40) : '赛题', vision, retryDelays }
585
688
  // 断点续跑:扫描本会话落盘的 v2 工作流记录,跳过已完成的迭代
586
689
  const resumeCount = fresh ? 0 : completedIterations(session)
587
690
  args.startIndex = resumeCount
@@ -627,6 +730,8 @@ export function apply(ctx) {
627
730
  }
628
731
  } catch (err) {
629
732
  return { kind: 'error', text: '启动失败: ' + String((err && err.message) || err) }
733
+ } finally {
734
+ starting = false
630
735
  }
631
736
  }
632
737
 
@@ -636,7 +741,7 @@ export function apply(ctx) {
636
741
  commands.register({
637
742
  name: 'loopbegin',
638
743
  description: '启动数学建模论文自动化流水线 v2(8 步骤:含图表视觉自检、评审修复闭环、论文定稿;每轮 19 次迭代、多轮外循环,成果全部落盘)',
639
- input: { hint: '--round=N 外循环轮数(默认 1);可选 --from 题目文件 指定赛题文件;--fresh 从头重跑' },
744
+ input: { hint: '--round=N 外循环轮数(默认 1);--from 文件名(支持部分匹配,自动查找根目录 .md 文档,如 --from 2024);--fresh 从头重跑' },
640
745
  handler: (invocation) => startPipeline(invocation.agent, invocation.rawInput),
641
746
  })
642
747
  } catch (err) { console.warn('dsh-mcmp: loopbegin 注册失败(可能被其他实例占用): ' + String((err && err.message) || err)) }
@@ -694,8 +799,9 @@ export function apply(ctx) {
694
799
  const agent = agents && agents.get(session.id)
695
800
  if (!agent) return
696
801
  console.log('dsh-mcmp: 检测到以 /loopbegin 开头的用户消息,自动启动流水线')
697
- const res = startPipeline(agent, firstLine)
698
- if (res && res.kind === 'error') pushLog('消息触发启动失败: ' + res.text)
802
+ Promise.resolve(startPipeline(agent, firstLine)).then((res) => {
803
+ if (res && res.kind === 'error') pushLog('消息触发启动失败: ' + res.text)
804
+ }).catch(() => { /* 忽略 */ })
699
805
  } catch (err) { /* 触发器自身异常忽略,不影响消息流 */ }
700
806
  }, { global: true })
701
807
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mcmp",
3
- "version": "2.1.4",
3
+ "version": "2.2.0",
4
4
  "description": "DeepSeek Harness 插件:数学建模论文自动化流水线 v2(8 步骤 × 19 迭代 × N 轮:图表视觉自检与修复、评审问题修复闭环、跨步骤缺陷台账与向上修复、论文兜底定稿、质疑驱动、断点续跑、进度面板)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",