sintbot 1.3.0 → 1.4.1

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 (81) hide show
  1. package/CHANGELOG.en.json +50 -0
  2. package/CHANGELOG.json +50 -0
  3. package/README.md +138 -20
  4. package/README.zh-CN.md +144 -20
  5. package/assets/design-styles/acid-pop.md +155 -0
  6. package/assets/design-styles/archive-index.md +161 -0
  7. package/assets/design-styles/art-deco.md +164 -0
  8. package/assets/design-styles/cinematic-noir.md +178 -0
  9. package/assets/design-styles/editorial.md +121 -0
  10. package/assets/design-styles/geometric-pop.md +122 -0
  11. package/assets/design-styles/hand-drawn.md +117 -0
  12. package/assets/design-styles/islamic-geometry.md +155 -0
  13. package/assets/design-styles/liquid-glass.md +183 -0
  14. package/assets/design-styles/neo-chinese.md +136 -0
  15. package/assets/design-styles/precision-mono.md +156 -0
  16. package/assets/design-styles/quiet-luxury.md +163 -0
  17. package/assets/design-styles/riso-print.md +116 -0
  18. package/assets/design-styles/soft-friendly.md +174 -0
  19. package/assets/design-styles/swiss-grid.md +156 -0
  20. package/assets/design-styles/tech-dark.md +182 -0
  21. package/bin/sintbot-v2.js +291 -263
  22. package/docs/AI-ASSISTED-INSTALL.md +8 -8
  23. package/docs/install.md +80 -48
  24. package/docs/zh-CN/AI-ASSISTED-INSTALL.md +8 -8
  25. package/docs/zh-CN/install.md +80 -48
  26. package/install.sh +34 -81
  27. package/package.json +60 -5
  28. package/src/agent/claudecode.js +32 -3
  29. package/src/agent/claudemodels.js +60 -0
  30. package/src/agent/claudestatus.js +74 -0
  31. package/src/agent/claudeusage.js +200 -0
  32. package/src/agent/codexapp.js +21 -4
  33. package/src/agent/codexcode.js +8 -3
  34. package/src/agent/codexstatus.js +181 -0
  35. package/src/agent/privatecore.js +1 -1
  36. package/src/agent/privatecore.wasm +0 -0
  37. package/src/c2configcontrol.js +214 -0
  38. package/src/c2console.js +149 -0
  39. package/src/c2incident.js +125 -0
  40. package/src/c2install.js +247 -37
  41. package/src/c2migration.js +100 -0
  42. package/src/c2multigroupcapacity.js +103 -0
  43. package/src/c2multigroupservice.js +418 -0
  44. package/src/cdp.js +254 -0
  45. package/src/config.js +13 -2
  46. package/src/core/c2durablejournal.js +26 -9
  47. package/src/core/c2durableruntime.js +104 -1
  48. package/src/core/c2inputrejection.js +45 -0
  49. package/src/core/c2localeffectexecutor.js +12 -1
  50. package/src/core/c2payloadhandles.js +11 -0
  51. package/src/core/c2persistrelation.js +20 -0
  52. package/src/core/c2persiststate.js +28 -2
  53. package/src/core/c2platformrecoverysender.js +1 -1
  54. package/src/core/c2sourcequarantine.js +56 -1
  55. package/src/core/c2terminalprune.js +2 -1
  56. package/src/core/engineslotsadapter.js +108 -0
  57. package/src/core/issues.js +185 -0
  58. package/src/core/ledger.js +30 -0
  59. package/src/core/proxy.js +424 -0
  60. package/src/core/store.js +12 -0
  61. package/src/core/v2engine.js +427 -31
  62. package/src/design.js +5161 -0
  63. package/src/gitrepo.js +150 -0
  64. package/src/leftover.js +174 -0
  65. package/src/platform/slack.js +1294 -0
  66. package/src/platform/wecom.js +961 -0
  67. package/src/platform/wecomcli.js +128 -0
  68. package/src/platform/wecomname.js +43 -0
  69. package/src/platform/wecompoll.js +241 -0
  70. package/src/preview.js +581 -0
  71. package/src/projectmemory.js +175 -0
  72. package/src/ui/auth.js +31 -0
  73. package/src/ui/browser.html +392 -0
  74. package/src/ui/browserroutes.js +234 -0
  75. package/src/ui/console.html +5719 -0
  76. package/src/ui/dashboard.js +168 -0
  77. package/src/ui/feishulogin.js +136 -0
  78. package/src/ui/serverbase.js +2371 -0
  79. package/src/ui/sessions.js +78 -0
  80. package/src/ui/takeover.js +108 -0
  81. package/src/usage.js +144 -0
@@ -14,13 +14,59 @@ const { terminalReplyText } = require('./c2terminaleffectbridge')
14
14
  const { createC2TurnProjection } = require('./c2turnprojection')
15
15
  const { prepareC2RunnerInput } = require('./c2runnerinput')
16
16
  const { continueC2Successor } = require('./c2successorbridge')
17
+ const { rejectC2Input } = require('./c2inputrejection')
17
18
  const NO_REPLY = 'NO_REPLY'
18
19
  const SYSTEM_APPENDIX = systemAppendix(NO_REPLY)
19
20
  const V2_NORMAL_ROUTE_MISSING = Object.freeze(['complete durable v2 authority'])
21
+ const DURABLE_V2_COMMANDS = new Set(['/help', '/status', '/model', '/new', '/hud', '/issue'])
22
+ function fmtWan(n) {
23
+ n = Number(n) || 0
24
+ if (n >= 100_000_000) return `${(n / 100_000_000).toFixed(1).replace(/\.0$/, '')}亿`
25
+ if (n >= 10_000) return `${(n / 10_000).toFixed(1).replace(/\.0$/, '')}万`
26
+ return String(n)
27
+ }
28
+ function humanDuration(ms) {
29
+ const seconds = Math.max(0, Math.round(ms / 1000))
30
+ if (seconds < 60) return `${seconds} 秒`
31
+ const minutes = Math.floor(seconds / 60)
32
+ if (minutes < 60) return `${minutes} 分 ${seconds % 60} 秒`
33
+ return `${Math.floor(minutes / 60)} 小时 ${minutes % 60} 分`
34
+ }
20
35
  const T = {
21
36
  queued: n => n > 1 ? `⏳ 已排队 — 当前任务结束后与前面 ${n - 1} 条一并处理` : '⏳ 已排队 — 当前任务结束后处理',
22
37
  stopped: '已停止',
23
38
  noActiveTask: '没有正在运行的任务',
39
+ unsupportedCommand: '这个命令暂不支持。要停止当前任务请发 /stop,其他需求直接用文字说明。',
40
+ unsupportedCommandWithModel: '这个命令暂不支持,发 /help 查看可用命令;其他需求直接用文字说明。',
41
+ help: steer => ['【可用命令】',
42
+ '/status 本群状态:模型、项目、是否在忙',
43
+ '/model 查看模型;/model 名字 切换(会开始新的一段对话)',
44
+ '/new 开始新的一段对话',
45
+ '/stop 停止当前任务,连同排队的一起取消',
46
+ '/hud 额度与本段对话的用量',
47
+ '/issue 一句话 记下问题反馈',
48
+ steer ? '直接发文字就是派任务;任务进行中再发的消息会补充进当前任务。'
49
+ : '直接发文字就是派任务;任务进行中再发的消息会排队,等这一轮结束再处理。'].join('\n'),
50
+ status: ({ model, project, state, queue, session }) => ['【本群状态】', `· 模型:${model}`,
51
+ `· 项目:${project}`, `· 状态:${state}`, `· 排队:${queue}`, `· 对话:${session}`].join('\n'),
52
+ freshSession: '✅ 已开始新的一段对话,之前聊的内容不再带着',
53
+ newBusy: '⚠ 手头还有任务在跑 —— 等它跑完,或先发 /stop 再发 /new',
54
+ quotaShow: (name, value) => `${name} 额度:${value}`,
55
+ quotaMiss: name => `${name} 额度暂时没查到,稍后再试`,
56
+ quotaNone: name => `模型「${name}」不支持查额度`,
57
+ sessionLine: (turns, ctx, inTotal) => `【对话】第 ${turns} 轮 · ${ctx ? `上下文约 ${fmtWan(ctx)} token` : `累计处理约 ${fmtWan(inTotal)} token`}`,
58
+ noSession: '【对话】本段对话还没开始',
59
+ issueUsage: '用法:/issue 后面跟一句话,说清哪里不对劲\n例:/issue 刚才发的图它没理我',
60
+ issueLogged: id => `📝 已记下 #${id}`,
61
+ issueFailed: '⚠ 没记下来 —— 麻烦再说一次,或直接在群里说',
62
+ modelInfo: (current, list, example) => `【当前模型】${current}\n【可用】${list}\n换模型:发「/model 名字」,例如 /model ${example}\n⚠ 换完是新的一段对话,之前聊的内容不再带着`,
63
+ modelUnknown: (want, names) => `⚠ 没有「${want}」这个模型\n【可用】${names}\n发「/model 名字」换,名字要一模一样`,
64
+ modelSame: current => `本群已经在用 ${current},没有变化`,
65
+ modelBusy: '⚠ 手头还有任务在跑,现在换会打断它 —— 等它跑完再换,或先发 /stop',
66
+ modelSwitched: current => `✅ 已切到 ${current}\n⚠ 新的一段对话从这里开始,之前聊的内容不再带着`,
67
+ modelSwitchFailed: '⚠ 没换成 —— 请管理员到控制台看一下',
68
+ stopPending: '正在停止当前任务,这条消息没有处理,请稍后重发。',
69
+ stopUnavailable: '当前任务正在收尾,暂时无法停止。',
24
70
  stoppedWithQueue: n => `已停止 — 连同排队的 ${n} 条一并取消`,
25
71
  cardFooter: ({ ms, tokens }) => {
26
72
  const parts = [`耗时 ${Math.max(0, Math.round(ms / 1000))} 秒`]
@@ -29,6 +75,32 @@ const T = {
29
75
  return parts.join(' · ')
30
76
  },
31
77
  }
78
+ // /stop with no stoppable turn is user input refused before any receipt; the
79
+ // running turn, if any, keeps its own durable path untouched.
80
+ function durableV2StopUnavailable(rt) {
81
+ const idle = !rt?.v2NormalRouteActive || !rt.v2RunningJobs?.length
82
+ return rejectC2Input('C2 v2 /stop is unsupported or closed', idle
83
+ ? { reason: 'stop-idle', notice: T.noActiveTask }
84
+ : { reason: 'stop-unavailable', notice: T.stopUnavailable })
85
+ }
86
+
87
+ // The binary owns quota/error and side-effect classification. This host route
88
+ // only admits the supported, closed Codex subscription failure; it never
89
+ // retries a runner or turns that failure into a successful outcome.
90
+ function supportedV2QuotaFailure(payload, runnerResult) {
91
+ if (!payload || payload.source !== 'result' || payload.aborted ||
92
+ payload.sessionMissing || payload.transientNetwork || payload.freshRetried ||
93
+ payload.resumedSessionCurrent || runnerResult?.aborted !== false ||
94
+ runnerResult?.result?.quota?.kind !== 'quota' ||
95
+ typeof payload.bufferText !== 'string' || payload.bufferText.trim() ||
96
+ typeof payload.answerText !== 'string' || payload.answerText.trim()) return false
97
+ const terminal = { ...payload, type: 'finalize' }
98
+ delete terminal.receiptId; delete terminal.turnToken; delete terminal.quiet
99
+ const outcome = engineTerminalStep({ finalized: false, outcome: null }, terminal).state.outcome
100
+ return outcome.status === 'error' && outcome.quotaError === true &&
101
+ outcome.sideEffects === false
102
+ }
103
+
32
104
  function ordinaryV2Cohort(snapshot, jobs, binding, chatId, receiptId) {
33
105
  if (!Array.isArray(jobs) || !jobs.length ||
34
106
  jobs.some(job => !job || job.msg?.batch || job.msg?.synthetic ||
@@ -71,7 +143,10 @@ class Engine {
71
143
  const { store, log = () => {}, durableV2Runtime, durableV2Payloads,
72
144
  durableV2StartTurn, durableV2ApplyLocalEffect, durableV2VerifyRunnerClosed,
73
145
  durableV2AssertWriterLock, durableV2Journal,
74
- durableV2NormalRoute = true, durableV2StreamFrames = false } = options
146
+ durableV2NormalRoute = true, durableV2StreamFrames = false,
147
+ durableV2AcquireCapacity = null, durableV2SealCapacity = null,
148
+ durableV2SwitchProvider = null, durableV2UsageProbe = null, durableV2Issues = null,
149
+ durableV2Version = '' } = options
75
150
  const sync = f => typeof f === 'function' && f.constructor?.name !== 'AsyncFunction'
76
151
  if (!cfg || !cfg.agent || !cfg.groups || !cfg.providers ||
77
152
  options.receiptJournal || options.unifiedStepHost ||
@@ -91,13 +166,20 @@ class Engine {
91
166
  typeof store?.getSession !== 'function' ||
92
167
  typeof store?.getSessionStats !== 'function' ||
93
168
  typeof store?.setSessionAtomic !== 'function' ||
94
- durableV2NormalRoute !== true || typeof durableV2StreamFrames !== 'boolean') {
169
+ durableV2NormalRoute !== true || typeof durableV2StreamFrames !== 'boolean' ||
170
+ durableV2AcquireCapacity !== null && typeof durableV2AcquireCapacity !== 'function' ||
171
+ durableV2SealCapacity !== null && !sync(durableV2SealCapacity) ||
172
+ durableV2SwitchProvider !== null && !sync(durableV2SwitchProvider) ||
173
+ durableV2UsageProbe !== null && typeof durableV2UsageProbe !== 'function' ||
174
+ durableV2Issues !== null && typeof durableV2Issues?.add !== 'function' ||
175
+ typeof durableV2Version !== 'string') {
95
176
  throw new TypeError('dedicated Engine requires complete durable v2 authority')
96
177
  }
97
178
  Object.assign(this, { cfg, store, log, durableV2Runtime, durableV2Payloads,
98
179
  durableV2StartTurn, durableV2ApplyLocalEffect, durableV2VerifyRunnerClosed,
99
180
  durableV2AssertWriterLock, durableV2Journal, durableV2NormalRoute,
100
- durableV2StreamFrames })
181
+ durableV2StreamFrames, durableV2AcquireCapacity, durableV2SealCapacity,
182
+ durableV2SwitchProvider, durableV2UsageProbe, durableV2Issues, durableV2Version })
101
183
  this.projectMemory = options.projectMemory || null
102
184
  this.repoInfo = options.repoInfo || null
103
185
  this.tz = normalizeTz(cfg.timezone)
@@ -108,6 +190,7 @@ class Engine {
108
190
  this.durableV2Runtime.close()
109
191
  let aborted = 0
110
192
  for (const rt of this.rts.values()) {
193
+ rt.v2AdmissionController?.abort()
111
194
  if (rt.v2Controller) {
112
195
  try { rt.v2Controller.abort() } catch {}
113
196
  aborted++
@@ -243,6 +326,106 @@ _resolveProvider(chatId, group) {
243
326
  return { name, provider: this.cfg.providers[name] }
244
327
  }
245
328
 
329
+ // Chat commands are answered before any receipt, like /stop with nothing to
330
+ // stop: nothing enters the journal and each returns its single reply as an
331
+ // input rejection. Commands that reset a session need the group idle and its
332
+ // journal settled, and share the console's configuration writer.
333
+ _durableV2GroupBusy(chatId) {
334
+ const rt = this.rts.get(chatId)
335
+ const pending = this.durableV2Runtime.snapshot().receipts?.pending?.some(item => item.chatId === chatId)
336
+ return !!(rt?.v2NormalRouteActive || rt?.v2AdmissionPending || rt?.v2Controller ||
337
+ rt?.v2QueueReceiptIds?.length || pending)
338
+ }
339
+
340
+ async _durableV2Command(msg, cmd, rest) {
341
+ const reject = (reason, notice) =>
342
+ rejectC2Input('C2 v2 chat command answered before receipt', { reason, notice })
343
+ const group = this._resolveGroup(msg.chatId)
344
+ if (!group || group.paused) return reject('unsupported-command', T.unsupportedCommandWithModel)
345
+ const { name, provider } = this._resolveProvider(msg.chatId, group)
346
+ const label = (n, p) => p?.model ? `${n} · ${p.model}` : n
347
+ if (cmd === '/model') return this._durableV2ModelCommand(msg, rest, group)
348
+ if (cmd === '/help') {
349
+ return reject('help', T.help(provider?.runner === 'codex' && group.codexSteer !== false))
350
+ }
351
+ if (cmd === '/status') {
352
+ const rt = this.rts.get(msg.chatId)
353
+ const queued = this.durableV2Runtime.snapshot().state?.lanes?.queue?.[msg.chatId]?.queue?.length || 0
354
+ const state = rt?.v2NormalRouteActive
355
+ ? `运行中(已跑 ${humanDuration(Date.now() - (rt.v2StartedAt || Date.now()))})`
356
+ : rt?.v2AdmissionPending ? '消息已登记,等待并发名额' : '空闲'
357
+ const project = String(group.workdir || '').split(/[\\/]/).filter(Boolean).pop() || '(未设)'
358
+ return reject('status', T.status({ model: label(name, provider), project, state, queue: queued,
359
+ session: this.store.getSession(msg.chatId) ? '进行中(记着上下文)' : '未开始' }))
360
+ }
361
+ if (cmd === '/new') {
362
+ if (this._durableV2GroupBusy(msg.chatId)) return reject('new-busy', T.newBusy)
363
+ let locked = false
364
+ try { locked = this.durableV2AssertWriterLock() === true } catch {}
365
+ if (!locked) throw new Error('C2 v2 /new lost single-writer lock')
366
+ this.store.resetSessionAndStats(msg.chatId)
367
+ this.log(`/new: ${msg.chatId} (by ${msg.senderName || 'unknown'})`)
368
+ return reject('new-session', T.freshSession)
369
+ }
370
+ if (cmd === '/hud') {
371
+ const lines = []
372
+ if (this.durableV2UsageProbe && provider) {
373
+ let usage = null
374
+ try { usage = await this.durableV2UsageProbe(name, provider) } catch {}
375
+ const value = typeof usage === 'string' ? usage : usage?.value || null
376
+ const supported = typeof usage === 'string' ? !!usage : !!usage && usage.supported !== false
377
+ lines.push(value ? T.quotaShow(name, value) : supported ? T.quotaMiss(name) : T.quotaNone(name))
378
+ }
379
+ const stats = this.store.getSessionStats(msg.chatId)
380
+ const session = this.store.getSession(msg.chatId)
381
+ lines.push(stats?.sessionId && stats.sessionId === session
382
+ ? T.sessionLine(stats.turns, stats.ctx || 0, stats.in || 0) : T.noSession)
383
+ return reject('hud', lines.join('\n'))
384
+ }
385
+ if (cmd === '/issue') {
386
+ if (!rest) return reject('issue-usage', T.issueUsage)
387
+ let record = null
388
+ try {
389
+ record = this.durableV2Issues?.add({ chatId: msg.chatId, group: group.name || '',
390
+ sender: msg.senderName || msg.userId || '', text: rest,
391
+ version: this.durableV2Version || '', turn: null }) || null
392
+ } catch (error) { this.log(`issue add failed: ${error?.message || String(error)}`) }
393
+ return record ? reject('issue-logged', T.issueLogged(record.id)) : reject('issue-failed', T.issueFailed)
394
+ }
395
+ return reject('unsupported-command', T.unsupportedCommandWithModel)
396
+ }
397
+
398
+ // /model lists models, or switches through the console's configuration
399
+ // writer. Switching needs this group idle and settled, and always starts a
400
+ // fresh session.
401
+ _durableV2ModelCommand(msg, want, group) {
402
+ const reject = (reason, notice) =>
403
+ rejectC2Input('C2 v2 model command answered before receipt', { reason, notice })
404
+ const label = (name, provider) => provider?.model ? `${name} · ${provider.model}` : name
405
+ const available = Object.entries(this.cfg.providers || {})
406
+ .filter(([, provider]) => provider && !provider.hidden &&
407
+ ['claude', 'codex'].includes(provider.runner))
408
+ const { name: currentName, provider: currentProvider } = this._resolveProvider(msg.chatId, group)
409
+ const current = label(currentName, currentProvider)
410
+ const list = available.map(([name, provider]) => label(name, provider)).join(' / ')
411
+ if (!want) {
412
+ const example = available.find(([name]) => name !== currentName)?.[0] || currentName
413
+ return reject('model-info', T.modelInfo(current, list, example))
414
+ }
415
+ const hit = available.find(([name]) => name.toLowerCase() === want.toLowerCase())
416
+ if (!hit) return reject('model-unknown', T.modelUnknown(want, available.map(([name]) => name).join(' / ')))
417
+ if (hit[0] === currentName) return reject('model-same', T.modelSame(current))
418
+ if (this._durableV2GroupBusy(msg.chatId)) return reject('model-busy', T.modelBusy)
419
+ try {
420
+ this.durableV2SwitchProvider(msg.chatId, hit[0])
421
+ } catch (error) {
422
+ this.log(`/model switch failed for ${msg.chatId} → ${hit[0]}: ${error?.message || String(error)}`)
423
+ return reject('model-failed', /settled/.test(error?.message || '') ? T.modelBusy : T.modelSwitchFailed)
424
+ }
425
+ this.log(`/model: ${msg.chatId} ${currentName} → ${hit[0]} (by ${msg.senderName || 'unknown'})`)
426
+ return reject('model-switched', T.modelSwitched(label(hit[0], this.cfg.providers[hit[0]])))
427
+ }
428
+
246
429
  async runDurableV2Message(msg, platform) {
247
430
  if (msg?.text?.trim() === '/stop') {
248
431
  return this.runDurableV2Stop(msg, platform)
@@ -254,13 +437,16 @@ async runDurableV2Message(msg, platform) {
254
437
  throw error
255
438
  }
256
439
  const authority = this.durableV2Runtime
440
+ const waiting = this.rts.get(msg?.chatId)
441
+ while (waiting?.v2QueueNoticePromise) await waiting.v2QueueNoticePromise
442
+ if (waiting?.v2StreamChain) await waiting.v2StreamChain
443
+ if (waiting?.v2CardBinding?.opened) await waiting.v2CardBinding.path.flushForTerminal()
257
444
  if (!authority || authority.blocked ||
258
445
  !msg || typeof msg !== 'object' || msg.synthetic || msg.cron ||
259
446
  msg.polled || msg.wantsAck || msg.resetSessionAfter || msg.quiet ||
260
447
  msg.batch ||
261
448
  msg.receiptSource && msg.receiptSource !== 'message' ||
262
449
  typeof msg.text !== 'string' || !msg.text.trim() ||
263
- msg.text.trim().startsWith('/') ||
264
450
  typeof msg.chatId !== 'string' || !msg.chatId ||
265
451
  typeof msg.msgId !== 'string' ||
266
452
  !/^om_[A-Za-z0-9_-]{1,124}$/.test(msg.msgId) ||
@@ -270,11 +456,21 @@ async runDurableV2Message(msg, platform) {
270
456
  typeof platform.createStreamingCard !== 'function') {
271
457
  throw new Error('C2 v2 ordinary Feishu message is unsupported or closed')
272
458
  }
459
+ // An unmigrated chat command is user input, not an authority fault. It is
460
+ // refused before admission with no receipt; the service decides delivery.
461
+ const command = /^(\/[a-z]+)(?:\s+([\s\S]*))?$/.exec(msg.text.trim())
462
+ if (command && this.durableV2SwitchProvider && DURABLE_V2_COMMANDS.has(command[1])) {
463
+ throw await this._durableV2Command(msg, command[1], (command[2] || '').trim())
464
+ }
465
+ if (msg.text.trim().startsWith('/')) {
466
+ throw rejectC2Input('C2 v2 ordinary Feishu message is unsupported or closed',
467
+ { reason: 'unsupported-command', notice: this.durableV2SwitchProvider
468
+ ? T.unsupportedCommandWithModel : T.unsupportedCommand })
469
+ }
273
470
  const group = this._resolveGroup(msg.chatId)
274
471
  const { provider } = group ? this._resolveProvider(msg.chatId, group) : {}
275
472
  if (!group || group.paused || group.platform && group.platform !== 'feishu' ||
276
- !provider || !['claude', 'codex'].includes(provider.runner) ||
277
- provider.runner === 'codex' && group.codexSteer !== false) {
473
+ !provider || !['claude', 'codex'].includes(provider.runner)) {
278
474
  throw new Error('C2 v2 ordinary group control is not migrated')
279
475
  }
280
476
  let locked = false
@@ -295,14 +491,41 @@ async runDurableV2Message(msg, platform) {
295
491
  ingressClosed: duplicate.ingressClosed }
296
492
  }
297
493
  if (rt.v2StopControl || before.state.lanes.stopIntent?.[msg.chatId]) {
298
- throw new Error('C2 v2 ordinary route is waiting for stop acknowledgement')
494
+ throw rejectC2Input('C2 v2 ordinary route is waiting for stop acknowledgement',
495
+ { reason: 'stop-pending', notice: T.stopPending })
299
496
  }
300
497
  const lane = before.state?.lanes
301
498
  const queue = lane?.queue?.[msg.chatId]?.queue
499
+ if (rt.v2AdmissionPending) {
500
+ if (!Array.isArray(queue) || !queue.length || rt.v2RunningJobs ||
501
+ lane?.turn?.[msg.chatId]?.active != null ||
502
+ lane?.handoff?.[msg.chatId]?.phase === 'running') {
503
+ authority.close()
504
+ throw new Error('C2 capacity wait differs from durable queued authority')
505
+ }
506
+ this.durableV2Payloads.auditOrdinaryQueue(msg.chatId, rt.v2QueueReceiptIds || [])
507
+ const queued = this.stageDurableV2Message(msg, platform)
508
+ // Preserve even the overflow source receipt, then seal rather than drop
509
+ // an acknowledged platform message or run an unbounded in-memory queue.
510
+ if (queue.length >= 3) {
511
+ authority.close()
512
+ throw new Error('C2 capacity wait queue exceeded its bound; receipts preserved')
513
+ }
514
+ return { receiptId: queued.receiptId, queued: true, staged: queued.staged,
515
+ waitingForCapacity: true, ingressClosed: !!authority.blocked }
516
+ }
302
517
  const busy = lane?.turn?.[msg.chatId]?.active != null ||
303
518
  lane?.handoff?.[msg.chatId]?.phase === 'running' ||
304
519
  Array.isArray(queue) && queue.length > 0 ||
305
520
  rt.v2RunningJobs || rt.v2QueueReceiptIds?.length
521
+ if (busy && provider.runner === 'codex' && group.codexSteer !== false &&
522
+ rt.v2SteerControl && rt.v2NormalRouteActive && !rt.v2RunnerStopVerified &&
523
+ !rt.v2TerminalStarted && !rt.v2StreamQuiescing && rt.v2Controller &&
524
+ !rt.v2Controller.signal.aborted && !rt.v2SteerClosing) {
525
+ this.durableV2Payloads.auditOrdinaryQueue(msg.chatId, rt.v2QueueReceiptIds || [])
526
+ const queued = this.stageDurableV2Message(msg, platform)
527
+ return this._runDurableV2Steer(rt, queued.receiptId)
528
+ }
306
529
  if (busy && rt.v2NormalRouteActive &&
307
530
  lane?.turn?.[msg.chatId]?.active === rt.v2TurnToken &&
308
531
  lane?.handoff?.[msg.chatId]?.phase === 'running' &&
@@ -370,13 +593,52 @@ async runDurableV2Message(msg, platform) {
370
593
  authority.close()
371
594
  throw new Error('C2 v2 ordinary receipt admission changed')
372
595
  }
373
- const dispatched = this.commitDurableV2InitialDispatch({ chatId: msg.chatId,
374
- receiptId: accepted.receiptId })
375
- const effectId = dispatched.effectsPending?.[0]
376
- const identity = { chatId: msg.chatId, receiptId: accepted.receiptId,
377
- turnToken: dispatched.turnToken, effectId }
378
596
  rt.v2NormalRouteActive = true
379
- const execution = this._drainDurableV2NormalChain(identity)
597
+ const execute = async () => {
598
+ let release = null
599
+ let failed = false
600
+ try {
601
+ let receiptId = accepted.receiptId
602
+ if (this.durableV2AcquireCapacity) {
603
+ const controller = new AbortController()
604
+ rt.v2AdmissionController = controller
605
+ rt.v2AdmissionPending = true
606
+ try {
607
+ release = await this.durableV2AcquireCapacity({ signal: controller.signal })
608
+ } catch (error) {
609
+ if (controller.signal.aborted && !rt.v2DispatchEffect &&
610
+ error?.code === 'C2_CAPACITY_WAIT_CANCELLED') {
611
+ return { receiptId, status: 'interruptedBeforeDispatch',
612
+ queued: true, ingressClosed: true }
613
+ }
614
+ throw error
615
+ }
616
+ if (typeof release !== 'function' || controller.signal.aborted ||
617
+ authority.blocked || this.durableV2AssertWriterLock() !== true) {
618
+ throw new Error('C2 capacity permit lost before durable dispatch')
619
+ }
620
+ rt.v2AdmissionPending = false
621
+ const queued = authority.snapshot().state.lanes.queue?.[msg.chatId]?.queue
622
+ this.durableV2Payloads.auditOrdinaryQueue(msg.chatId, rt.v2QueueReceiptIds || [])
623
+ receiptId = v2QueueSelection(queued).anchor
624
+ }
625
+ const dispatched = this.commitDurableV2InitialDispatch({ chatId: msg.chatId, receiptId })
626
+ return await this._drainDurableV2NormalChain({ chatId: msg.chatId, receiptId,
627
+ turnToken: dispatched.turnToken, effectId: dispatched.effectsPending?.[0] })
628
+ } catch (error) {
629
+ // Seal shared grants before releasing this turn's permit. Waiting
630
+ // siblings must not dispatch in the microtask before the service sees
631
+ // this rejection and begins its process-wide stop.
632
+ failed = true
633
+ this.durableV2SealCapacity?.(error)
634
+ throw error
635
+ } finally {
636
+ rt.v2AdmissionPending = false
637
+ rt.v2AdmissionController = null
638
+ if (release) this._releaseDurableV2Capacity(rt, release, failed)
639
+ }
640
+ }
641
+ const execution = execute()
380
642
  rt.v2NormalRoutePromise = execution.then(() => true, () => {
381
643
  rt.v2NormalRouteFailure = true
382
644
  return false
@@ -389,6 +651,72 @@ async runDurableV2Message(msg, platform) {
389
651
  }
390
652
  }
391
653
 
654
+ // A failed route returns its shared model permit only once its runner is
655
+ // proven closed or proven never started. Otherwise the permit stays taken
656
+ // until the process exits, so a sibling group never runs beside a child that
657
+ // may still be alive.
658
+ _releaseDurableV2Capacity(rt, release, failed) {
659
+ const closed = () => !rt.v2Controller && (rt.v2RunnerStopVerified === true ||
660
+ rt.v2RunnerNotStarted === true)
661
+ const stop = rt.v2RunnerStopPromise
662
+ if (!failed || !rt.v2Controller && (!stop || closed())) return release()
663
+ const kept = () => this.log(`group ${rt.chatId}: model permit kept until restart; runner close unproven`)
664
+ if (!stop) return kept()
665
+ stop.then(() => {
666
+ if (!closed()) return kept()
667
+ // A failed binary release closes the shared capacity itself.
668
+ try { release() } catch (error) { this.log(`group ${rt.chatId}: model permit release failed: ${error.message}`) }
669
+ }, kept)
670
+ }
671
+
672
+ async _runDurableV2Steer(rt, receiptId) {
673
+ const authority = this.durableV2Runtime
674
+ const chatId = rt.chatId
675
+ const job = this.durableV2Payloads.requireQueued(receiptId)
676
+ const identity = authority.prepareSteer({ receiptId, chatId,
677
+ parentReceiptId: rt.v2DispatchEffect.receiptIds.at(-1),
678
+ turnToken: rt.v2TurnToken, generation: rt.v2RunnerGeneration,
679
+ payloadHandles: this.durableV2Payloads })
680
+ this.durableV2Payloads.takeSteer(receiptId, chatId, identity.effectId)
681
+ rt.v2QueueReceiptIds = authority.scopeState('queue', chatId).queue
682
+ .map(item => item.id.slice('receipt:job:'.length))
683
+ const entry = { identity, job, landed: false, accepted: false }
684
+ rt.v2SteerEntries.set(receiptId, entry)
685
+ const control = rt.v2SteerControl
686
+ const work = (async () => {
687
+ const text = `[${fmtStamp(job.msg.createTimeMs || Date.now(), this.tz)} ${job.msg.senderName || job.msg.userId || 'user'}] [群里新消息:关联当前任务就立即调整;独立任务先完成当前任务再处理,并分别回应] ${job.msg.text.trim()}`
688
+ const accepted = await authority.executeSteer(identity, {
689
+ assertWriterLock: this.durableV2AssertWriterLock,
690
+ invoke: () => control.steerDurable({ msgId: job.msg.msgId, text }) })
691
+ entry.accepted = accepted
692
+ if (accepted) {
693
+ if (entry.landed) authority.commitSteerObservation({ ...identity, type: 'landed' })
694
+ return { receiptId, steered: true, accepted: true, landed: entry.landed, staged: true }
695
+ }
696
+ if (entry.landed) throw new Error('C2 rejected steer also landed; outcome inconsistent')
697
+ rt.v2SteerEntries.delete(receiptId)
698
+ this.durableV2Payloads.attach(receiptId, job)
699
+ rt.v2QueueReceiptIds = authority.scopeState('queue', chatId).queue
700
+ .map(item => item.id.slice('receipt:job:'.length))
701
+ // A prior readiness-wait queue notice is already acknowledged.
702
+ const priorNotice = [...authority.snapshot().effects].some(([id, status]) =>
703
+ status === 'acked' && this.durableV2Journal.effectDescriptor(id)?.type === 'queueNotice' &&
704
+ this.durableV2Journal.effectDescriptor(id)?.receiptId === receiptId)
705
+ if (!priorNotice) {
706
+ const notice = authority.prepareQueueNotice({ receiptId, chatId, payloadHandles: this.durableV2Payloads })
707
+ await authority.executeQueueNotice({ ...notice, payloadHandles: this.durableV2Payloads,
708
+ assertWriterLock: this.durableV2AssertWriterLock,
709
+ perform: ({ target }) => job.platform.replyOnce(target, T.queued(notice.visibleQueue)),
710
+ confirm: response => response?.code === 0 && /^om_[A-Za-z0-9_-]{1,124}$/.test(response?.data?.message_id || '') })
711
+ }
712
+ return { receiptId, queued: true, staged: true, accepted: false }
713
+ })()
714
+ rt.v2QueueNoticePromise = work
715
+ try { return await work }
716
+ catch (error) { authority.close(); rt.v2Controller?.abort(); this.durableV2SealCapacity?.(error); throw error }
717
+ finally { if (rt.v2QueueNoticePromise === work) rt.v2QueueNoticePromise = null }
718
+ }
719
+
392
720
  async runDurableV2Stop(msg, platform) {
393
721
  const authority = this.durableV2Runtime
394
722
  const chatId = msg?.chatId
@@ -401,12 +729,14 @@ async runDurableV2Stop(msg, platform) {
401
729
  !/^om_[A-Za-z0-9_-]{1,124}$/.test(msg.msgId) ||
402
730
  msg.replyCtx?.chatId !== chatId ||
403
731
  msg.replyCtx?.messageId !== msg.msgId ||
404
- typeof platform?.replyOnce !== 'function' ||
405
- !rt?.v2NormalRouteActive || !rt.v2RunningJobs?.length ||
732
+ typeof platform?.replyOnce !== 'function') {
733
+ throw new Error('C2 v2 /stop is unsupported or closed')
734
+ }
735
+ if (!rt?.v2NormalRouteActive || !rt.v2RunningJobs?.length ||
406
736
  !rt.v2Controller || rt.v2TerminalStarted ||
407
737
  rt.v2CancelControl || rt.v2StopControl || rt.v2StreamQuiescing ||
408
738
  rt.v2TimeoutProof) {
409
- throw new Error('C2 v2 /stop is unsupported or closed')
739
+ throw durableV2StopUnavailable(rt)
410
740
  }
411
741
  const group = this._resolveGroup(chatId)
412
742
  if (!group || group.paused || group.platform && group.platform !== 'feishu') {
@@ -424,7 +754,7 @@ async runDurableV2Stop(msg, platform) {
424
754
  rt.v2StreamQuiescing || rt.v2TimeoutProof ||
425
755
  rt.v2RunnerGeneration !== requestedGeneration ||
426
756
  rt.v2TurnToken !== requestedTurnToken) {
427
- throw new Error('C2 v2 /stop is unsupported or closed')
757
+ throw durableV2StopUnavailable(rt)
428
758
  }
429
759
  const before = authority.snapshot()
430
760
  const controlReceipt = { source: 'message', chatId, sourceId: msg.msgId,
@@ -701,7 +1031,8 @@ async _drainDurableV2NormalChain(initial) {
701
1031
  delete terminal.quiet
702
1032
  const outcome = engineTerminalStep({ finalized: false, outcome: null },
703
1033
  terminal).state.outcome
704
- if (['error', 'empty', 'stopped'].includes(outcome.status)) {
1034
+ const quotaFailure = supportedV2QuotaFailure(payload, rt.v2RunnerResult)
1035
+ if (['error', 'empty', 'stopped'].includes(outcome.status) && !quotaFailure) {
705
1036
  const classified = this.classifyDurableV2UnresolvedTurn({ ...identity,
706
1037
  payload })
707
1038
  const error = new Error('C2 v2 ordinary result requires fault recovery')
@@ -710,9 +1041,9 @@ async _drainDurableV2NormalChain(initial) {
710
1041
  throw error
711
1042
  }
712
1043
  if (payload.source !== 'result' || payload.sessionMissing ||
713
- payload.hasQuota || payload.transientNetwork || payload.freshRetried ||
1044
+ (payload.hasQuota && !quotaFailure) || payload.transientNetwork || payload.freshRetried ||
714
1045
  payload.resumedSessionCurrent ||
715
- !['ok', 'no-reply'].includes(outcome.status)) {
1046
+ (!['ok', 'no-reply'].includes(outcome.status) && !quotaFailure)) {
716
1047
  authority.close()
717
1048
  const error = new Error('C2 v2 ordinary result needs a retry or notice decision')
718
1049
  error.code = 'C2_V2_UNRESOLVED_TURN'
@@ -730,7 +1061,7 @@ async _drainDurableV2NormalChain(initial) {
730
1061
  for (const settledReceiptId of completed.settledReceiptIds ||
731
1062
  [identity.receiptId]) {
732
1063
  settled.push({ receiptId: settledReceiptId,
733
- status: silent ? 'silent' : 'answered',
1064
+ status: quotaFailure ? 'error' : silent ? 'silent' : 'answered',
734
1065
  terminalEffectId: completed.terminalEffectId })
735
1066
  }
736
1067
  if (authority.blocked) {
@@ -956,8 +1287,53 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
956
1287
  if (!provider || (provider.baseUrl && !provider.apiKey)) {
957
1288
  throw new Error('C2 v2 runner provider unavailable')
958
1289
  }
959
- if (provider.runner === 'codex' && job.group.codexSteer !== false) {
960
- throw new Error('C2 v2 follow-up control has no durable authority')
1290
+ rt.v2SteerControl = null
1291
+ rt.v2SteerEntries = new Map()
1292
+ rt.v2SteerClosing = false
1293
+ rt.v2SteerLandedChain = Promise.resolve()
1294
+ const steerIdentity = { chatId, parentReceiptId: receiptId, turnToken, generation }
1295
+ const steerAlive = () => rt.v2RunnerGeneration === generation && !rt.v2SteerClosing &&
1296
+ !controller?.signal.aborted && !rt.v2TerminalStarted
1297
+ const onSteerReady = control => {
1298
+ if (!steerAlive()) return
1299
+ if (typeof control?.steerDurable !== 'function') {
1300
+ authority.close(); controller?.abort(); return
1301
+ }
1302
+ rt.v2SteerControl = control
1303
+ // Receipts arriving before control readiness already have a durable
1304
+ // queue notice. Pull only those still queued in this generation.
1305
+ const drain = async () => {
1306
+ if (rt.v2QueueNoticePromise) await rt.v2QueueNoticePromise
1307
+ await rt.v2StreamChain
1308
+ if (rt.v2CardBinding?.opened) await rt.v2CardBinding.path.flushForTerminal()
1309
+ for (const id of [...(rt.v2QueueReceiptIds || [])]) {
1310
+ if (!steerAlive()) break
1311
+ if (!rt.v2QueueReceiptIds?.includes(id)) continue
1312
+ await this._runDurableV2Steer(rt, id)
1313
+ }
1314
+ }
1315
+ rt.v2SteerDrain = drain().catch(error => {
1316
+ authority.close(); controller?.abort(); throw error
1317
+ })
1318
+ rt.v2SteerDrain.catch(() => {})
1319
+ }
1320
+ const onSteerLanded = msgId => {
1321
+ if (!steerAlive()) return
1322
+ const entry = [...rt.v2SteerEntries.values()].find(value => value.job.msg.msgId === msgId)
1323
+ if (!entry || entry.landed) return
1324
+ // Keep the trusted observation in process while the RPC ACK is still
1325
+ // pending. A crash here remains unknown; it never authorizes replay.
1326
+ entry.landed = true
1327
+ if (!entry.accepted) return
1328
+ const observation = rt.v2SteerLandedChain.then(async () => {
1329
+ while (rt.v2QueueNoticePromise) await rt.v2QueueNoticePromise
1330
+ await rt.v2StreamChain
1331
+ if (rt.v2CardBinding?.opened) await rt.v2CardBinding.path.flushForTerminal()
1332
+ if (rt.v2RunnerGeneration !== generation) return
1333
+ authority.commitSteerObservation({ ...steerIdentity, receiptId: entry.identity.receiptId, type: 'landed' })
1334
+ })
1335
+ rt.v2SteerLandedChain = observation
1336
+ observation.catch(() => { authority.close(); controller?.abort() })
961
1337
  }
962
1338
  const prompt = this._prepareRunnerPrompt({ msg: job.msg, group: job.group,
963
1339
  platform: job.platform, providerName: name, provider, rt,
@@ -967,6 +1343,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
967
1343
  rt.v2Controller = controller
968
1344
  rt.v2RunnerStopPromise = null
969
1345
  rt.v2RunnerStopVerified = false
1346
+ rt.v2RunnerNotStarted = false
970
1347
  let streamChain = Promise.resolve()
971
1348
  let streamFailure = null
972
1349
  const enqueueFrame = event => {
@@ -1042,6 +1419,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1042
1419
  return ack
1043
1420
  },
1044
1421
  onSpawnFailed: value => capability.onSpawnFailed(value),
1422
+ onSteerReady, onSteerLanded,
1045
1423
  log: this.log })
1046
1424
  let started
1047
1425
  try { started = this.durableV2StartTurn(input) }
@@ -1062,10 +1440,19 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1062
1440
  let result
1063
1441
  try {
1064
1442
  result = await running
1443
+ rt.v2SteerClosing = true
1444
+ rt.v2SteerControl = null
1445
+ if (rt.v2SteerDrain) await rt.v2SteerDrain
1065
1446
  if (rt.v2QueueNoticePromise) await rt.v2QueueNoticePromise
1066
1447
  await rt.v2RunnerStopPromise
1067
1448
  await streamChain
1449
+ await rt.v2SteerLandedChain
1068
1450
  if (streamFailure) throw streamFailure
1451
+ const steer = authority.scopeState('steer', chatId)
1452
+ if (!rt.v2CancelControl && !rt.v2StopControl && !rt.v2TimeoutProof &&
1453
+ steer?.entries.some(entry => entry.status !== 'landed')) {
1454
+ throw new Error('C2 accepted steer lacks userMessage landing; replay forbidden')
1455
+ }
1069
1456
  if (rt.v2CardBinding?.opened && !rt.v2StreamQuiescing) {
1070
1457
  await rt.v2CardBinding.path.flushForTerminal()
1071
1458
  }
@@ -1114,6 +1501,10 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1114
1501
  aborted: settledResult.aborted, assertWriterLock: this.durableV2AssertWriterLock,
1115
1502
  store: this.store })
1116
1503
  rt.v2SessionCloseEventId = session.eventId
1504
+ if (rt.v2CancelControl || rt.v2StopControl || rt.v2TimeoutProof) {
1505
+ authority.cancelClosedSteer({ chatId, parentReceiptId: receiptId, turnToken,
1506
+ generation, assertWriterLock: this.durableV2AssertWriterLock })
1507
+ }
1117
1508
  }
1118
1509
  rt.v2RunnerResult = settledResult // process-local only; no reply until terminal effect
1119
1510
  rt.v2TerminalProjection = projected
@@ -1138,6 +1529,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1138
1529
  rt.v2RunnerGeneration = null
1139
1530
  rt.v2RunningJobs = null
1140
1531
  rt.v2DispatchEffect = null
1532
+ rt.v2RunnerNotStarted = true
1141
1533
  return { effectId, accepted: false, startFailureRecovered: true }
1142
1534
  }
1143
1535
  if (capability && !accepted) {
@@ -1168,6 +1560,7 @@ async completeDurableV2CardTurn({ chatId, receiptId, turnToken, effectId,
1168
1560
  const generation = rt?.v2RunnerGeneration
1169
1561
  const projected = rt?.v2TerminalProjection
1170
1562
  const resultText = projected?.payload?.resultText
1563
+ const quotaFailure = supportedV2QuotaFailure(payload, rt?.v2RunnerResult)
1171
1564
  if (!/^[0-9a-f]{32}$/.test(receiptId || '') ||
1172
1565
  !Number.isSafeInteger(turnToken) || turnToken <= 0 ||
1173
1566
  typeof effectId !== 'string' || !effectId ||
@@ -1199,11 +1592,11 @@ async completeDurableV2CardTurn({ chatId, receiptId, turnToken, effectId,
1199
1592
  (!isDeepStrictEqual(payload.batchReceiptIds, cohort.receiptIds) ||
1200
1593
  payload.replyTargetReceiptId !== receiptId)) ||
1201
1594
  !isDeepStrictEqual(payload, projected.payload) ||
1202
- // The isolated bridge currently implements only a successful final
1203
- // answer. An abort, provider error, quota or lost-session result needs
1204
- // its own durable retry/failover/notice decision before any card call.
1205
- payload.source !== 'result' || payload.aborted || payload.isError ||
1206
- payload.sessionMissing || payload.hasQuota || payload.transientNetwork ||
1595
+ // Only successful answers and binary-classified, side-effect-free
1596
+ // quota failures use this no-retry terminal bridge. Unknown failures
1597
+ // and lost-session outcomes still need a separate recovery decision.
1598
+ payload.source !== 'result' || payload.aborted || (payload.isError && !quotaFailure) ||
1599
+ payload.sessionMissing || (payload.hasQuota && !quotaFailure) || payload.transientNetwork ||
1207
1600
  payload.freshRetried || payload.resumedSessionCurrent ||
1208
1601
  (replyText !== undefined && replyText !== terminalReplyText(projected.payload)) ||
1209
1602
  rt.v2TerminalStarted) {
@@ -1226,7 +1619,9 @@ async completeDurableV2CardTurn({ chatId, receiptId, turnToken, effectId,
1226
1619
  replyCtx: job.msg.replyCtx, receiptId, chatId, turnToken, generation,
1227
1620
  verifyGeneration, applyLocalEffect: this.durableV2ApplyLocalEffect,
1228
1621
  assertWriterLock: this.durableV2AssertWriterLock,
1229
- redactText: text => redact(text, { pii: job.group?.redactPii !== false }),
1622
+ redactText: text => redact(quotaFailure
1623
+ ? 'Codex 订阅额度已用完,本次任务未完成。请恢复额度后重新发送;本次请求不会自动重试。'
1624
+ : text, { pii: job.group?.redactPii !== false }),
1230
1625
  sendPlainReply: typeof job.platform.replyOnce === 'function'
1231
1626
  ? (target, text) => job.platform.replyOnce(target.replyCtx, text)
1232
1627
  : null })
@@ -1455,8 +1850,9 @@ requestDurableV2Stop(chatId) {
1455
1850
  return true
1456
1851
  } catch (error) { authority?.close(); this.log(error.message); return false }
1457
1852
  }
1458
- if (rt.v2RunningJobs?.length === 1 &&
1459
- rt.v2RunningJobs[0]?.msg?.msgId === msgId && !rt.v2TerminalStarted) {
1853
+ if ((rt.v2SteerEntries?.has(receiptId) ||
1854
+ rt.v2RunningJobs?.length === 1 && rt.v2RunningJobs[0]?.msg?.msgId === msgId) &&
1855
+ !rt.v2TerminalStarted) {
1460
1856
  rt.v2CancelControl = 'recall'
1461
1857
  if (rt.v2Controller) { try { rt.v2Controller.abort() } catch { authority?.close() } }
1462
1858
  return true