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.
- package/CHANGELOG.en.json +50 -0
- package/CHANGELOG.json +50 -0
- package/README.md +138 -20
- package/README.zh-CN.md +144 -20
- package/assets/design-styles/acid-pop.md +155 -0
- package/assets/design-styles/archive-index.md +161 -0
- package/assets/design-styles/art-deco.md +164 -0
- package/assets/design-styles/cinematic-noir.md +178 -0
- package/assets/design-styles/editorial.md +121 -0
- package/assets/design-styles/geometric-pop.md +122 -0
- package/assets/design-styles/hand-drawn.md +117 -0
- package/assets/design-styles/islamic-geometry.md +155 -0
- package/assets/design-styles/liquid-glass.md +183 -0
- package/assets/design-styles/neo-chinese.md +136 -0
- package/assets/design-styles/precision-mono.md +156 -0
- package/assets/design-styles/quiet-luxury.md +163 -0
- package/assets/design-styles/riso-print.md +116 -0
- package/assets/design-styles/soft-friendly.md +174 -0
- package/assets/design-styles/swiss-grid.md +156 -0
- package/assets/design-styles/tech-dark.md +182 -0
- package/bin/sintbot-v2.js +291 -263
- package/docs/AI-ASSISTED-INSTALL.md +8 -8
- package/docs/install.md +80 -48
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +8 -8
- package/docs/zh-CN/install.md +80 -48
- package/install.sh +34 -81
- package/package.json +60 -5
- package/src/agent/claudecode.js +32 -3
- package/src/agent/claudemodels.js +60 -0
- package/src/agent/claudestatus.js +74 -0
- package/src/agent/claudeusage.js +200 -0
- package/src/agent/codexapp.js +21 -4
- package/src/agent/codexcode.js +8 -3
- package/src/agent/codexstatus.js +181 -0
- package/src/agent/privatecore.js +1 -1
- package/src/agent/privatecore.wasm +0 -0
- package/src/c2configcontrol.js +214 -0
- package/src/c2console.js +149 -0
- package/src/c2incident.js +125 -0
- package/src/c2install.js +247 -37
- package/src/c2migration.js +100 -0
- package/src/c2multigroupcapacity.js +103 -0
- package/src/c2multigroupservice.js +418 -0
- package/src/cdp.js +254 -0
- package/src/config.js +13 -2
- package/src/core/c2durablejournal.js +26 -9
- package/src/core/c2durableruntime.js +104 -1
- package/src/core/c2inputrejection.js +45 -0
- package/src/core/c2localeffectexecutor.js +12 -1
- package/src/core/c2payloadhandles.js +11 -0
- package/src/core/c2persistrelation.js +20 -0
- package/src/core/c2persiststate.js +28 -2
- package/src/core/c2platformrecoverysender.js +1 -1
- package/src/core/c2sourcequarantine.js +56 -1
- package/src/core/c2terminalprune.js +2 -1
- package/src/core/engineslotsadapter.js +108 -0
- package/src/core/issues.js +185 -0
- package/src/core/ledger.js +30 -0
- package/src/core/proxy.js +424 -0
- package/src/core/store.js +12 -0
- package/src/core/v2engine.js +427 -31
- package/src/design.js +5161 -0
- package/src/gitrepo.js +150 -0
- package/src/leftover.js +174 -0
- package/src/platform/slack.js +1294 -0
- package/src/platform/wecom.js +961 -0
- package/src/platform/wecomcli.js +128 -0
- package/src/platform/wecomname.js +43 -0
- package/src/platform/wecompoll.js +241 -0
- package/src/preview.js +581 -0
- package/src/projectmemory.js +175 -0
- package/src/ui/auth.js +31 -0
- package/src/ui/browser.html +392 -0
- package/src/ui/browserroutes.js +234 -0
- package/src/ui/console.html +5719 -0
- package/src/ui/dashboard.js +168 -0
- package/src/ui/feishulogin.js +136 -0
- package/src/ui/serverbase.js +2371 -0
- package/src/ui/sessions.js +78 -0
- package/src/ui/takeover.js +108 -0
- package/src/usage.js +144 -0
package/src/core/v2engine.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
960
|
-
|
|
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
|
-
//
|
|
1203
|
-
//
|
|
1204
|
-
//
|
|
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(
|
|
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.
|
|
1459
|
-
rt.v2RunningJobs[0]?.msg?.msgId === msgId &&
|
|
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
|