@goodandready/dsh-moa 0.2.36 → 0.2.37

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/README.md CHANGED
@@ -170,7 +170,7 @@ Seamlessly retains synthesized baseline code from prior turns while pruning inte
170
170
  `report_generation_enabled` generates comprehensive Markdown & JSON reports detailing candidate metrics, agreement scores, test gate results, and cost breakdowns via `GET /dsh-moa/runs/:id/report`.
171
171
 
172
172
  ### 18. Graceful Degradation & Local Fallback Resilience
173
- When `local_fallback_enabled: true`, candidates that fail due to network outages or rate limits (429/500) automatically recover using configured local models (Ollama / MiniPC).
173
+ When `local_fallback_enabled: true`, candidates that fail due to network outages, timeouts, or rate limits (429/500) automatically recover using configured local models (Ollama / MiniPC). Each fallback model operates with an independent execution budget and an isolated controller linked to the parent turn signal, preventing cloud timeouts from starving fallback models. Additionally, terminal errors and aborted streams are strictly classified, ensuring reliable error propagation and failover.
174
174
 
175
175
  ### 6. Live Canvas 1-Click Preview (optional)
176
176
  If `@goodandready/dsh-live-canvas` is installed in the same profile, `dsh-moa` pushes the promoted HTML file to the Live Canvas REST contract (`POST /dsh-live-canvas/api/preview`, served by the same harness webServer) and appends a one-click preview link (`/dsh-live-canvas/sandbox/<id>`) to the answer. Without the plugin the step is skipped silently — no errors in the log, no dead links.
package/README.ru.md CHANGED
@@ -170,7 +170,7 @@ graph TD
170
170
  При включении `report_generation_enabled` генерируется подробный аналитический отчет с матрицей моделей, временами задержек, оценками судей, расходами и результатами тестов. Отчет доступен по API `GET /dsh-moa/runs/:id/report?format=md`.
171
171
 
172
172
  ### 18. Плавная деградация и локальный фоллбэк
173
- Опция `local_fallback_enabled` позволяет задать резервные локальные модели (Ollama, vLLM, MiniPC), к которым плагин бесшовно обращается при сбоях сетевых API или превышении лимитов rate limit (429/500).
173
+ Опция `local_fallback_enabled` позволяет задать резервные локальные модели (Ollama, vLLM, MiniPC), к которым плагин бесшовно обращается при сбоях сетевых API, таймаутах или превышении лимитов rate limit (429/500). Каждая фоллбэк-модель запускается с независимым бюджетом времени и изолированным контроллером отмены, связанным с сигналом основного хода, предотвращая исчерпание времени ожидания из-за задержек облачных провайдеров. Стриминговые сбои и терминальные ошибки классифицируются типизированными исключениями с гарантированной защитой от позднего промоушна.
174
174
 
175
175
  ### 6. Live Canvas 1-клик предпросмотр (опционально)
176
176
  Если в профиле установлен `@goodandready/dsh-live-canvas`, `dsh-moa` отправляет промоученный HTML-файл в REST-контракт Live Canvas (`POST /dsh-live-canvas/api/preview`, тот же webServer харнесса) и добавляет к ответу ссылку на предпросмотр (`/dsh-live-canvas/sandbox/<id>`). Без плагина шаг пропускается тихо — без ошибок в журнале и без битых ссылок.
package/README.zh.md CHANGED
@@ -142,6 +142,9 @@ graph TD
142
142
  ### 8. 提升前 Git 快照检查点 (`dsh-time-machine`)
143
143
  在将胜出候选者的文件提升并覆盖至项目根目录前,`dsh-moa` 自动调用本地 `dsh-time-machine` 建立影子 Git 检查点 (`moa-pre-promotion: candidate-N`)。如未安装则静默回退,不阻断提升流程。
144
144
 
145
+ ### 18. 平滑降级与本地容灾恢复 (Local Fallback Resilience)
146
+ 当 `local_fallback_enabled: true` 时,因网络中断、超时或速率限制(429/500)而失败的候选模型会自动使用配置的本地模型(Ollama / MiniPC)恢复。每个后备模型都拥有独立的超时执行配额与关联主 turn signal 的隔离取消控制器,防止云端超时耗尽本地恢复时间。同时严格检测终止错误与流中断,确保安全容灾且杜绝已取消 turn 的延迟文件提升。
147
+
145
148
  ### 6. Live Canvas 一键预览(可选)
146
149
  若同一 profile 中安装了 `@goodandready/dsh-live-canvas`,`dsh-moa` 会将提升后的 HTML 文件推送到 Live Canvas 的 REST 契约(`POST /dsh-live-canvas/api/preview`,由同一 harness webServer 提供服务),并在回答中附上一键预览链接(`/dsh-live-canvas/sandbox/<id>`)。未安装该插件时此步骤静默跳过——日志无报错,也不会出现死链接。
147
150
 
@@ -99,6 +99,7 @@ export async function runReferencesParallel(references, messages, options = {},
99
99
  }
100
100
 
101
101
  const runOne = async () => {
102
+ let candidateTimer = null
102
103
  try {
103
104
  const callPromise = callWithTransientRetry(
104
105
  callLlm,
@@ -115,14 +116,12 @@ export async function runReferencesParallel(references, messages, options = {},
115
116
  )
116
117
 
117
118
  const timeoutPromise = new Promise((_, reject) => {
118
- const timer = setTimeout(() => {
119
+ candidateTimer = setTimeout(() => {
119
120
  bestEffort('candidate timeout abort', () => {
120
121
  abortControllers[i].abort(new Error(`Timeout after ${Math.round(timeoutMs / 1000)}s`))
121
122
  })
122
123
  reject(new Error(`Timeout after ${Math.round(timeoutMs / 1000)}s`))
123
124
  }, timeoutMs)
124
- timer.unref?.()
125
- abortControllers[i].signal.addEventListener('abort', () => clearTimeout(timer), { once: true })
126
125
  })
127
126
 
128
127
  const res = await Promise.race([callPromise, timeoutPromise])
@@ -153,16 +152,51 @@ export async function runReferencesParallel(references, messages, options = {},
153
152
  onProgress(`✅ *Candidate ${i + 1}/${total} (${label}, T=${effectiveTemperature}) finished${costStr}* | Live total: ~\$${currentTotalCost.toFixed(4)}\n`)
154
153
  }
155
154
  } catch (err) {
155
+ if (candidateTimer) clearTimeout(candidateTimer)
156
+
157
+ // If turn was aborted by caller, do NOT run fallbacks
158
+ if (options.signal?.aborted) {
159
+ finishedCount++
160
+ results[i] = {
161
+ index: i + 1,
162
+ slot,
163
+ label,
164
+ role_persona: role,
165
+ temperature: effectiveTemperature,
166
+ text: '[aborted]',
167
+ usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
168
+ costUsd: 0,
169
+ ok: false,
170
+ error: 'Turn aborted',
171
+ }
172
+ return
173
+ }
174
+
156
175
  // Feature 10: Local fallback models if online candidate fails
157
176
  let fallbackRecovered = false
158
177
  if (options.local_fallback_enabled && Array.isArray(options.local_fallback_models) && options.local_fallback_models.length > 0) {
159
178
  for (const fbSlot of options.local_fallback_models) {
179
+ if (options.signal?.aborted) break
160
180
  const fbLabel = slotLabel(fbSlot)
181
+ const fbAbortCtrl = new AbortController()
182
+ let fbTimer = null
183
+ let removeParentAbort = null
184
+
185
+ if (options.signal) {
186
+ const onParentAbort = () => fbAbortCtrl.abort(options.signal.reason || new Error('Turn aborted'))
187
+ options.signal.addEventListener('abort', onParentAbort, { once: true })
188
+ removeParentAbort = () => options.signal.removeEventListener('abort', onParentAbort)
189
+ }
190
+
191
+ const fbTimeoutMs = typeof options.fallbackTimeoutMs === 'number' && options.fallbackTimeoutMs > 0
192
+ ? options.fallbackTimeoutMs
193
+ : Math.max(15000, timeoutMs)
194
+
161
195
  try {
162
196
  if (typeof onProgress === 'function') {
163
197
  onProgress(`🔄 *Candidate ${i + 1} (${label}) failed, attempting local fallback to ${fbLabel}...*\n`)
164
198
  }
165
- const fbRes = await callWithTransientRetry(
199
+ const fbCallPromise = callWithTransientRetry(
166
200
  callLlm,
167
201
  {
168
202
  provider: fbSlot.provider,
@@ -170,11 +204,22 @@ export async function runReferencesParallel(references, messages, options = {},
170
204
  messages: slotMessages,
171
205
  temperature: effectiveTemperature,
172
206
  maxTokens: options.maxTokens ?? 4096,
173
- timeoutMs,
174
- signal: abortControllers[i].signal,
207
+ timeoutMs: fbTimeoutMs,
208
+ signal: fbAbortCtrl.signal,
175
209
  },
176
210
  0
177
211
  )
212
+
213
+ const fbTimeoutPromise = new Promise((_, reject) => {
214
+ fbTimer = setTimeout(() => {
215
+ bestEffort('fallback timeout abort', () => {
216
+ fbAbortCtrl.abort(new Error(`Timeout after ${Math.round(fbTimeoutMs / 1000)}s`))
217
+ })
218
+ reject(new Error(`Timeout after ${Math.round(fbTimeoutMs / 1000)}s`))
219
+ }, fbTimeoutMs)
220
+ })
221
+
222
+ const fbRes = await Promise.race([fbCallPromise, fbTimeoutPromise])
178
223
  const fbText = typeof fbRes === 'string' ? fbRes : (fbRes?.content || fbRes?.text || '')
179
224
  const fbUsage = (typeof fbRes === 'object' && fbRes?.usage) ? fbRes.usage : {
180
225
  inputTokens: Math.max(1, Math.round(slotMessages.map((m) => m.content).join('').length / 4)),
@@ -202,6 +247,9 @@ export async function runReferencesParallel(references, messages, options = {},
202
247
  break
203
248
  } catch (fbErr) {
204
249
  logger.warn(`[dsh-moa] Fallback ${fbLabel} also failed:`, fbErr?.message || fbErr)
250
+ } finally {
251
+ if (fbTimer) clearTimeout(fbTimer)
252
+ if (removeParentAbort) removeParentAbort()
205
253
  }
206
254
  }
207
255
  }
package/lib/moa-llm.js CHANGED
@@ -1,17 +1,49 @@
1
+ function combineSignals(signal, timeoutSignal) {
2
+ if (!signal) return timeoutSignal
3
+ if (typeof AbortSignal.any === 'function') {
4
+ try {
5
+ return AbortSignal.any([signal, timeoutSignal])
6
+ } catch {
7
+ // fallback if signal is not an instance of AbortSignal
8
+ }
9
+ }
10
+ const combinedCtrl = new AbortController()
11
+ const onAbort = () => {
12
+ const reason = signal.aborted ? (signal.reason || new Error('Turn aborted')) : timeoutSignal.reason
13
+ combinedCtrl.abort(reason)
14
+ }
15
+ if (signal.aborted || timeoutSignal.aborted) {
16
+ onAbort()
17
+ return combinedCtrl.signal
18
+ }
19
+ signal.addEventListener?.('abort', onAbort, { once: true })
20
+ timeoutSignal.addEventListener?.('abort', onAbort, { once: true })
21
+ return combinedCtrl.signal
22
+ }
23
+
1
24
  /**
2
25
  * Unified LLM Dispatcher using ctx.llm.prepareCall / stream
3
26
  */
4
27
 
5
28
  export function createLlmCaller(ctx) {
6
- return async ({ provider, model, messages, temperature = 0.6, maxTokens = 4096, timeoutMs, onStreamDelta }) => {
29
+ return async ({ provider, model, messages, temperature = 0.6, maxTokens = 4096, timeoutMs, onStreamDelta, signal, stop, reasoningEffort }) => {
7
30
  if (!ctx.llm) {
8
31
  throw new Error('ctx.llm is not available in cordis context')
9
32
  }
10
33
 
34
+ const callConfig = {
35
+ provider,
36
+ model,
37
+ ...(temperature !== undefined ? { temperature } : {}),
38
+ ...(maxTokens !== undefined ? { maxTokens } : {}),
39
+ ...(stop !== undefined ? { stop } : {}),
40
+ ...(reasoningEffort !== undefined ? { reasoningEffort } : {}),
41
+ }
42
+
11
43
  let prep
12
44
  try {
13
- // DSH 0.1.2-rc.1 API expects { provider, model } object
14
- prep = await ctx.llm.prepareCall({ provider, model })
45
+ // DSH 0.2.0-rc.2 API expects LlmCallConfig object, supports signal
46
+ prep = await ctx.llm.prepareCall(callConfig, signal)
15
47
  } catch (err) {
16
48
  if (typeof ctx.llm.prepareCall === 'function') {
17
49
  try {
@@ -27,23 +59,33 @@ export function createLlmCaller(ctx) {
27
59
  throw new Error(`LLM provider/model ${provider}:${model} could not be prepared`)
28
60
  }
29
61
 
62
+ if (signal?.aborted) {
63
+ throw (signal.reason || new Error('Turn aborted'))
64
+ }
65
+
30
66
  const effectiveTimeoutMs = typeof timeoutMs === 'number' && timeoutMs > 0 ? timeoutMs : 120000
31
67
  const abortCtrl = new AbortController()
32
68
  const timeoutId = setTimeout(() => abortCtrl.abort(new Error(`LLM call timeout after ${Math.round(effectiveTimeoutMs / 1000)}s`)), effectiveTimeoutMs)
33
69
  timeoutId.unref?.()
70
+ const combinedSignal = combineSignals(signal, abortCtrl.signal)
34
71
 
35
72
  try {
73
+ const streamConfig = prep.config || callConfig
36
74
  const stream = prep.stream({
75
+ ...streamConfig,
37
76
  messages,
38
- temperature,
39
- maxTokens,
40
- signal: abortCtrl.signal,
77
+ temperature: streamConfig.temperature ?? temperature,
78
+ maxTokens: streamConfig.maxTokens ?? maxTokens,
79
+ signal: combinedSignal,
41
80
  })
42
81
 
43
82
  let fullText = ''
44
83
  let usageInfo = null
45
84
 
46
85
  for await (const chunk of stream) {
86
+ if (combinedSignal?.aborted) {
87
+ throw (combinedSignal.reason || new Error('Turn aborted'))
88
+ }
47
89
  if (chunk.type === 'text-delta' && typeof chunk.text === 'string') {
48
90
  fullText += chunk.text
49
91
  if (typeof onStreamDelta === 'function') {
@@ -53,9 +95,35 @@ export function createLlmCaller(ctx) {
53
95
  if (!fullText) fullText = chunk.block.text
54
96
  } else if (chunk.type === 'usage' && chunk.usage) {
55
97
  usageInfo = chunk.usage
98
+ } else if (chunk.type === 'finish') {
99
+ const reason = chunk.reason
100
+ if (reason && (reason.kind === 'error' || reason.kind === 'aborted')) {
101
+ const failure = reason.failure || {}
102
+ const errMsg = failure.message || failure.error?.message || reason.message || reason.error?.message || `LLM stream finished with ${reason.kind}`
103
+ const err = new Error(errMsg)
104
+ err.code = failure.code || reason.code || (reason.kind === 'error' ? 'LLM_ERROR' : 'ABORT_ERR')
105
+ if (failure.cause) err.cause = failure.cause
106
+ throw err
107
+ }
108
+ } else if (chunk.type === 'error') {
109
+ const errMsg = chunk.error?.message || chunk.message || 'LLM stream error'
110
+ const err = new Error(errMsg)
111
+ err.code = chunk.code || chunk.error?.code || 'LLM_ERROR'
112
+ if (chunk.error?.cause) err.cause = chunk.error.cause
113
+ throw err
56
114
  }
57
115
  }
58
116
 
117
+ if (combinedSignal?.aborted) {
118
+ throw (combinedSignal.reason || new Error('Turn aborted'))
119
+ }
120
+
121
+ if (!fullText.trim()) {
122
+ const err = new Error('LLM call returned empty response')
123
+ err.code = 'EMPTY_RESPONSE'
124
+ throw err
125
+ }
126
+
59
127
  clearTimeout(timeoutId)
60
128
  return {
61
129
  content: fullText,
package/lib/moa-runner.js CHANGED
@@ -207,6 +207,11 @@ export async function runMoAPipeline({
207
207
  }
208
208
  }
209
209
 
210
+ if (signal?.aborted) {
211
+ await cleanMoaWorkspaces(cwd)
212
+ return { error: new Error('Turn aborted') }
213
+ }
214
+
210
215
  // 5b. Test Execution Gate
211
216
  await executeTestGateForCandidates({
212
217
  cwd,
@@ -292,6 +297,10 @@ export async function runMoAPipeline({
292
297
 
293
298
  // 7. Fast Mode (1 candidate, bypass judge)
294
299
  if (isFastMode) {
300
+ if (signal?.aborted) {
301
+ await cleanMoaWorkspaces(cwd)
302
+ return { error: new Error('Turn aborted') }
303
+ }
295
304
  const single = successfulRefs[0]
296
305
  let promotedFiles = []
297
306
  if (single.files?.length > 0) {
@@ -348,6 +357,11 @@ export async function runMoAPipeline({
348
357
  }
349
358
  }
350
359
 
360
+ if (signal?.aborted) {
361
+ await cleanMoaWorkspaces(cwd)
362
+ return { error: new Error('Turn aborted') }
363
+ }
364
+
351
365
  // 7b. Consilium Round 2 (Peer Critique) if enabled
352
366
  if (isPeerCritiqueEnabled && successfulRefs.length > 1) {
353
367
  if (typeof onProgress === 'function') {
@@ -404,6 +418,11 @@ export async function runMoAPipeline({
404
418
 
405
419
  // 7c. Feature 2: Multi-Judge Panel & Consensus Voting
406
420
  let multiJudgeResult = null
421
+ if (signal?.aborted) {
422
+ await cleanMoaWorkspaces(cwd)
423
+ return { error: new Error('Turn aborted') }
424
+ }
425
+
407
426
  if (preset?.multi_judge_enabled && successfulRefs.length > 1) {
408
427
  multiJudgeResult = await executeMultiJudgePanel({
409
428
  judges: (preset.judge_models && preset.judge_models.length > 0) ? preset.judge_models : [primaryJudge],
@@ -488,6 +507,11 @@ export async function runMoAPipeline({
488
507
  successfulRefs.map((r, i) => `### Candidate ${i + 1} (${r.label})\n${r.text}`).join('\n\n')
489
508
  }
490
509
 
510
+ if (signal?.aborted) {
511
+ await cleanMoaWorkspaces(cwd)
512
+ return { error: new Error('Turn aborted') }
513
+ }
514
+
491
515
  // 9. Evaluate winner & promote files
492
516
  const winningIndex = (multiJudgeResult?.consensus && multiJudgeResult?.winningCandidateIndex)
493
517
  ? multiJudgeResult.winningCandidateIndex
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-moa",
3
- "version": "0.2.36",
3
+ "version": "0.2.37",
4
4
  "description": "Mixture of Agents (MoA) plugin for DeepSeek Harness with /moa slash command",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",