sintbot 1.4.16 → 1.4.18
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 +62 -0
- package/CHANGELOG.json +62 -0
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/bin/sintbot-v2.js +82 -4
- package/docs/AI-ASSISTED-INSTALL.md +4 -4
- package/docs/install.md +62 -10
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +4 -4
- package/docs/zh-CN/install.md +62 -10
- package/install.sh +1 -1
- package/package.json +4 -1
- package/src/agent/claudecode.js +41 -3
- package/src/agent/claudeusage.js +239 -5
- package/src/agent/envconflicts.js +74 -0
- package/src/agent/privatecore.js +86 -4
- package/src/agent/privatecore.wasm +0 -0
- package/src/agent/runner.js +9 -1
- package/src/c2configcontrol.js +7 -0
- package/src/c2console.js +3 -0
- package/src/c2install.js +1 -1
- package/src/c2multigroupservice.js +1 -1
- package/src/connect.js +566 -0
- package/src/core/c2runnerinput.js +47 -1
- package/src/core/v2engine.js +211 -11
- package/src/platform/feishu.js +22 -7
- package/src/setupserver.js +447 -0
- package/src/ui/console.html +161 -128
- package/src/ui/serverbase.js +19 -10
- package/src/usage.js +131 -38
package/src/core/v2engine.js
CHANGED
|
@@ -9,12 +9,13 @@ const { isDeepStrictEqual } = require('node:util')
|
|
|
9
9
|
const { redact } = require('./redact')
|
|
10
10
|
const { fmtStamp, normalizeTz } = require('./time')
|
|
11
11
|
const { settleUsage, manifestPolicy, manifestHeader, manifestContext, manifestFeatures, manifestSkills,
|
|
12
|
-
followupEnabled, systemAppendix, turnPrompt, engineTerminalStep, engineQueueStep
|
|
12
|
+
followupEnabled, systemAppendix, turnPrompt, engineTerminalStep, engineQueueStep,
|
|
13
|
+
idleCompactPlan, idleCompactSettle, compactEvent, classifyError } = require('../agent/privatecore')
|
|
13
14
|
const { taskId } = require('./enginecheckpoint')
|
|
14
15
|
const { prepareCardTerminalPath } = require('./c2cardterminalpath')
|
|
15
16
|
const { terminalReplyText } = require('./c2terminaleffectbridge')
|
|
16
17
|
const { createC2TurnProjection } = require('./c2turnprojection')
|
|
17
|
-
const { prepareC2RunnerInput } = require('./c2runnerinput')
|
|
18
|
+
const { prepareC2RunnerInput, prepareC2CompactionInput } = require('./c2runnerinput')
|
|
18
19
|
const { continueC2Successor } = require('./c2successorbridge')
|
|
19
20
|
const { rejectC2Input } = require('./c2inputrejection')
|
|
20
21
|
const progress = require('./turnprogress')
|
|
@@ -119,7 +120,8 @@ const T = {
|
|
|
119
120
|
quotaShow: (name, value) => `${name} 额度:${value}`,
|
|
120
121
|
quotaMiss: name => `${name} 额度暂时没查到,稍后再试`,
|
|
121
122
|
quotaNone: name => `模型「${name}」不支持查额度`,
|
|
122
|
-
sessionLine: (turns, ctx, inTotal) => `【对话】第 ${turns} 轮 · ${ctx ? `上下文约 ${fmtWan(ctx)} token` : `累计处理约 ${fmtWan(inTotal)} token`}
|
|
123
|
+
sessionLine: (turns, ctx, inTotal, { idleAt = 0, lastAt = '' } = {}) => `【对话】第 ${turns} 轮 · ${ctx ? `上下文约 ${fmtWan(ctx)} token` : `累计处理约 ${fmtWan(inTotal)} token`}`
|
|
124
|
+
+ (idleAt > 0 ? `(空闲时过 ${fmtWan(idleAt)} 先整理${lastAt ? ` · 上次整理 ${lastAt}` : ''})` : ''),
|
|
123
125
|
noSession: '【对话】本段对话还没开始',
|
|
124
126
|
issueUsage: '用法:/issue 后面跟一句话,说清哪里不对劲\n例:/issue 刚才发的图它没理我',
|
|
125
127
|
issueLogged: id => `📝 已记下 #${id}`,
|
|
@@ -297,7 +299,8 @@ class Engine {
|
|
|
297
299
|
durableV2SwitchProvider = null, durableV2SetBrain = null, durableV2ModelCatalog = null,
|
|
298
300
|
durableV2ModelCard = null,
|
|
299
301
|
durableV2UsageProbe = null, durableV2Issues = null,
|
|
300
|
-
durableV2Version = '', durableV2DataDir = '', durableV2ConsoleEnabled = false
|
|
302
|
+
durableV2Version = '', durableV2DataDir = '', durableV2ConsoleEnabled = false,
|
|
303
|
+
durableV2CompactSession = null, durableV2CapacitySnapshot = null } = options
|
|
301
304
|
const sync = f => typeof f === 'function' && f.constructor?.name !== 'AsyncFunction'
|
|
302
305
|
if (!cfg || !cfg.agent || !cfg.groups || !cfg.providers ||
|
|
303
306
|
options.receiptJournal || options.unifiedStepHost ||
|
|
@@ -326,7 +329,9 @@ class Engine {
|
|
|
326
329
|
durableV2ModelCard !== null && !sync(durableV2ModelCard) ||
|
|
327
330
|
durableV2UsageProbe !== null && typeof durableV2UsageProbe !== 'function' ||
|
|
328
331
|
durableV2Issues !== null && typeof durableV2Issues?.add !== 'function' ||
|
|
329
|
-
typeof durableV2Version !== 'string'
|
|
332
|
+
typeof durableV2Version !== 'string' ||
|
|
333
|
+
durableV2CompactSession !== null && typeof durableV2CompactSession !== 'function' ||
|
|
334
|
+
durableV2CapacitySnapshot !== null && !sync(durableV2CapacitySnapshot)) {
|
|
330
335
|
throw new TypeError('dedicated Engine requires complete durable v2 authority')
|
|
331
336
|
}
|
|
332
337
|
Object.assign(this, { cfg, store, log, durableV2Runtime, durableV2Payloads,
|
|
@@ -335,7 +340,7 @@ class Engine {
|
|
|
335
340
|
durableV2StreamFrames, durableV2AcquireCapacity, durableV2SealCapacity,
|
|
336
341
|
durableV2SwitchProvider, durableV2SetBrain, durableV2ModelCatalog, durableV2ModelCard,
|
|
337
342
|
durableV2UsageProbe, durableV2Issues, durableV2Version,
|
|
338
|
-
durableV2DataDir, durableV2ConsoleEnabled })
|
|
343
|
+
durableV2DataDir, durableV2ConsoleEnabled, durableV2CompactSession, durableV2CapacitySnapshot })
|
|
339
344
|
this.projectMemory = options.projectMemory || null
|
|
340
345
|
this.repoInfo = options.repoInfo || null
|
|
341
346
|
this.activity = options.activity || null
|
|
@@ -351,9 +356,11 @@ class Engine {
|
|
|
351
356
|
|
|
352
357
|
shutdown() {
|
|
353
358
|
this.durableV2Runtime.close()
|
|
359
|
+
this._closing = true // 关停中不再起空闲整理
|
|
354
360
|
let aborted = 0
|
|
355
361
|
for (const rt of this.rts.values()) {
|
|
356
362
|
rt.v2AdmissionController?.abort()
|
|
363
|
+
this._stopIdleCompact(rt, '关停', { force: true }) // 整理一并停掉(不等压缩边界),不算被打断的轮次(它没有收据)
|
|
357
364
|
if (rt.v2Controller) {
|
|
358
365
|
try { rt.v2Controller.abort() } catch {}
|
|
359
366
|
aborted++
|
|
@@ -372,14 +379,16 @@ async drainDurableV2(timeoutMs = 12_000) {
|
|
|
372
379
|
if (active.some(rt => rt.v2NormalRouteFailure)) return false
|
|
373
380
|
const runs = active.filter(rt => rt.v2RunnerStopPromise || rt.v2Controller)
|
|
374
381
|
const routes = active.filter(rt => rt.v2NormalRouteActive)
|
|
375
|
-
|
|
382
|
+
const compacting = active.filter(rt => rt.v2Compacting?.exited)
|
|
383
|
+
if (!runs.length && !routes.length && !compacting.length) return true
|
|
376
384
|
if (runs.some(rt => !rt.v2RunnerStopPromise) ||
|
|
377
385
|
routes.some(rt => !rt.v2NormalRoutePromise)) return false
|
|
378
386
|
let timer
|
|
379
387
|
try {
|
|
380
388
|
const results = await Promise.race([
|
|
381
389
|
Promise.all([...runs.map(rt => rt.v2RunnerStopPromise),
|
|
382
|
-
...routes.map(rt => rt.v2NormalRoutePromise)
|
|
390
|
+
...routes.map(rt => rt.v2NormalRoutePromise),
|
|
391
|
+
...compacting.map(rt => rt.v2Compacting.exited.then(closed => closed === true, () => false))]),
|
|
383
392
|
new Promise(resolve => { timer = setTimeout(() => resolve(null), timeoutMs) }),
|
|
384
393
|
])
|
|
385
394
|
return Array.isArray(results) && results.every(value => value === true)
|
|
@@ -546,6 +555,9 @@ async _durableV2Command(msg, cmd, rest) {
|
|
|
546
555
|
let locked = false
|
|
547
556
|
try { locked = this.durableV2AssertWriterLock() === true } catch {}
|
|
548
557
|
if (!locked) throw new Error('C2 v2 /new lost single-writer lock')
|
|
558
|
+
const stopping = this._stopIdleCompact(this.rts.get(msg.chatId), '开新会话')
|
|
559
|
+
if (stopping && await stopping !== true) return reject('new-busy', T.newBusy)
|
|
560
|
+
if (this.durableV2AssertWriterLock() !== true || this._durableV2GroupBusy(msg.chatId)) return reject('new-busy', T.newBusy)
|
|
549
561
|
this.store.resetSessionAndStats(msg.chatId)
|
|
550
562
|
this.log(`/new: ${msg.chatId} (by ${msg.senderName || 'unknown'})`)
|
|
551
563
|
return reject('new-session', T.freshSession)
|
|
@@ -561,8 +573,12 @@ async _durableV2Command(msg, cmd, rest) {
|
|
|
561
573
|
}
|
|
562
574
|
const stats = this.store.getSessionStats(msg.chatId)
|
|
563
575
|
const session = this.store.getSession(msg.chatId)
|
|
576
|
+
const hudRt = this.rts.get(msg.chatId)
|
|
577
|
+
const idleAt = provider && provider.runner === 'claude' && this._idleCompactCapability(hudRt, name) === true
|
|
578
|
+
? idleCompactPlan({ threshold: this._idleCompactThreshold(provider), now: 0 }).at : 0
|
|
564
579
|
lines.push(stats?.sessionId && stats.sessionId === session
|
|
565
|
-
? T.sessionLine(stats.turns, stats.ctx || 0, stats.in || 0
|
|
580
|
+
? T.sessionLine(stats.turns, stats.ctx || 0, stats.in || 0,
|
|
581
|
+
{ idleAt, lastAt: stats.compactedAt ? fmtStamp(stats.compactedAt, this.tz).slice(-5) : '' }) : T.noSession)
|
|
566
582
|
return reject('hud', lines.join('\n'))
|
|
567
583
|
}
|
|
568
584
|
if (cmd === '/issue') {
|
|
@@ -790,6 +806,9 @@ async _durableV2ApplyModel(chatId, group, pw, { sender = 'unknown', said = '', s
|
|
|
790
806
|
const current = brainLabel(name, this._brain(group, name, provider))
|
|
791
807
|
if (!switching && model === undefined && effort === undefined) return { kind: 'same', text: T.modelSame(current), label: current }
|
|
792
808
|
if (this._durableV2GroupBusy(chatId)) return reject('busy', T.modelBusy)
|
|
809
|
+
const stopping = this._stopIdleCompact(this.rts.get(chatId), '会话已重置')
|
|
810
|
+
if (stopping && await stopping !== true) return reject('busy', T.modelBusy)
|
|
811
|
+
if (this.durableV2AssertWriterLock() !== true || this._durableV2GroupBusy(chatId)) return reject('busy', T.modelBusy)
|
|
793
812
|
let r
|
|
794
813
|
try {
|
|
795
814
|
if (this.durableV2SetBrain) {
|
|
@@ -1002,6 +1021,13 @@ async runDurableV2Message(msg, platform) {
|
|
|
1002
1021
|
let release = null
|
|
1003
1022
|
let failed = false
|
|
1004
1023
|
try {
|
|
1024
|
+
// 空闲整理不占群锁:收据已登记,起轮前先停掉整理并等它退出(同一个会话文件不能两个进程同时写)。
|
|
1025
|
+
// 等待期间按「等并发名额」对外:后到的消息走 waitingForCapacity 那一支排队,而不是撞上已关闭的忙时路由。
|
|
1026
|
+
if (rt.v2Compacting && !(await this._awaitIdleCompactStop(rt))) {
|
|
1027
|
+
this._observeActivity('unknown', { chatId: msg.chatId })
|
|
1028
|
+
return { receiptId: accepted.receiptId, status: 'interruptedBeforeDispatch',
|
|
1029
|
+
queued: true, ingressClosed: true }
|
|
1030
|
+
}
|
|
1005
1031
|
let receiptId = accepted.receiptId
|
|
1006
1032
|
if (this.durableV2AcquireCapacity) {
|
|
1007
1033
|
const controller = new AbortController()
|
|
@@ -1049,11 +1075,15 @@ async runDurableV2Message(msg, platform) {
|
|
|
1049
1075
|
rt.v2NormalRouteFailure = true
|
|
1050
1076
|
return false
|
|
1051
1077
|
})
|
|
1078
|
+
let outcome = null
|
|
1052
1079
|
try {
|
|
1053
|
-
|
|
1080
|
+
outcome = await execution
|
|
1081
|
+
return outcome
|
|
1054
1082
|
} finally {
|
|
1055
1083
|
rt.v2NormalRouteActive = false
|
|
1056
1084
|
rt.v2NormalRoutePromise = null
|
|
1085
|
+
// 群已空闲、槽已还:看这一轮要不要接一次空闲整理(同步判定,起跑后才异步)。
|
|
1086
|
+
this._maybeIdleCompact(rt, outcome?.status)
|
|
1057
1087
|
}
|
|
1058
1088
|
}
|
|
1059
1089
|
|
|
@@ -2074,6 +2104,9 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
|
|
|
2074
2104
|
onEvent: event => {
|
|
2075
2105
|
if (event.type === 'system' && event.subtype === 'context')
|
|
2076
2106
|
this._observeActivity('observe', { chatId, receiptId, generation, event })
|
|
2107
|
+
// CLI 在 init 里自报的可用命令:含 compact 才能空闲整理(按这一家 provider 记)。
|
|
2108
|
+
if (event.type === 'system' && event.subtype === 'init' && Array.isArray(event.slashCommands))
|
|
2109
|
+
rt.v2CanCompact = { provider: this._resolveProvider(chatId, job.group).name, ok: event.slashCommands.includes('compact') }
|
|
2077
2110
|
},
|
|
2078
2111
|
onTurnAccepted: value => {
|
|
2079
2112
|
const ack = capability.onTurnAccepted(value)
|
|
@@ -2084,6 +2117,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
|
|
|
2084
2117
|
onSpawnFailed: value => capability.onSpawnFailed(value),
|
|
2085
2118
|
onSteerReady, onSteerLanded,
|
|
2086
2119
|
log: this.log })
|
|
2120
|
+
rt.v2Compacted = false // 「进度可能已折叠」只带一轮
|
|
2087
2121
|
let started
|
|
2088
2122
|
try { started = this.durableV2StartTurn(input) }
|
|
2089
2123
|
catch (error) { started = Promise.reject(error) }
|
|
@@ -2506,11 +2540,177 @@ classifyDurableV2UnresolvedTurn({ chatId, receiptId, turnToken, effectId,
|
|
|
2506
2540
|
// Reinject current supported context each turn. No legacy feature menu,
|
|
2507
2541
|
// external-message commands or v1 manifest refresh scheduler is included.
|
|
2508
2542
|
return turnPrompt({ lines: batch?.length > 1 ? batch.map(line) : [line(msg)],
|
|
2509
|
-
cancelled: null, compacted:
|
|
2543
|
+
cancelled: null, compacted: rt?.v2Compacted === true, manifest, updated: false })
|
|
2510
2544
|
}
|
|
2511
2545
|
|
|
2512
2546
|
handleMessage() { throw new Error('legacy Engine execution path is not enabled') }
|
|
2513
2547
|
|
|
2548
|
+
// ── 空闲整理(ABI 37:核心 idle_compact_plan / idle_compact_settle 定规则,这里只起停进程)──────────
|
|
2549
|
+
// 一轮正常收尾、群里没人在等时,对同一个 Claude 会话跑一次无头 /compact,把压缩从成员等着的轮次挪到间隙。
|
|
2550
|
+
// 没有收据的运行不进持久日志:只占一个并发槽、写 store 的会话统计、记守护进程日志。不占群锁(rt.v2Compacting
|
|
2551
|
+
// 只是句柄):新消息照常走空闲路径,起轮前先 _stopIdleCompact 并等进程退出;/new、换型号、关停同样先停。
|
|
2552
|
+
_idleCompactThreshold(provider) {
|
|
2553
|
+
const raw = Number(provider?.autocompactTokens) || 0
|
|
2554
|
+
return raw > 0 ? Math.min(1_000_000, Math.max(100_000, Math.floor(raw))) : 0
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
_idleCompactCapability(rt, name) {
|
|
2558
|
+
return rt?.v2CanCompact && rt.v2CanCompact.provider === name ? rt.v2CanCompact.ok : null
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
_maybeIdleCompact(rt, status) {
|
|
2562
|
+
if (!this.durableV2CompactSession || !this.durableV2CapacitySnapshot || !rt) return
|
|
2563
|
+
try {
|
|
2564
|
+
const chatId = rt.chatId
|
|
2565
|
+
const group = this.cfg.groups?.[chatId]
|
|
2566
|
+
if (!group) return
|
|
2567
|
+
const { name, provider } = this._resolveProvider(chatId, group)
|
|
2568
|
+
if (!provider || provider.runner !== 'claude') return
|
|
2569
|
+
const sid = this.store.getSession(chatId) || null
|
|
2570
|
+
const stats = this.store.getSessionStats(chatId) || null
|
|
2571
|
+
const cap = this.durableV2CapacitySnapshot()
|
|
2572
|
+
const freeSlots = cap && !cap.closed ? Math.max(0, cap.maxConcurrent - cap.active - cap.waiting) : 0
|
|
2573
|
+
const outcome = status === 'answered' ? 'ok' : status === 'silent' ? 'no-reply' : String(status || '')
|
|
2574
|
+
const plan = idleCompactPlan({ threshold: this._idleCompactThreshold(provider), ctx: stats?.ctx || 0,
|
|
2575
|
+
canCompact: this._idleCompactCapability(rt, name), outcome,
|
|
2576
|
+
busy: this._durableV2GroupBusy(chatId) || !!rt.v2Compacting,
|
|
2577
|
+
queued: rt.v2QueueReceiptIds?.length || 0,
|
|
2578
|
+
paused: group.paused === true, closing: this._closing === true,
|
|
2579
|
+
sessionMatch: !!(sid && stats && stats.sessionId === sid), freeSlots,
|
|
2580
|
+
restUntil: rt.v2CompactRest && rt.v2CompactRest.sid === sid ? rt.v2CompactRest.until : null,
|
|
2581
|
+
now: Date.now() })
|
|
2582
|
+
if (!plan.start) {
|
|
2583
|
+
if (plan.at > 0 && (stats?.ctx || 0) >= plan.at) this.log(`group ${chatId}: 空闲整理未起 — ${plan.reason}`)
|
|
2584
|
+
return
|
|
2585
|
+
}
|
|
2586
|
+
this._startIdleCompact(rt, { group, name, provider, sid, ctx: stats.ctx, plan })
|
|
2587
|
+
} catch (error) {
|
|
2588
|
+
this.log(`group ${rt.chatId}: 空闲整理判定出错(忽略): ${error.message}`)
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
_startIdleCompact(rt, { group, name, provider, sid, ctx, plan }) {
|
|
2593
|
+
const chatId = rt.chatId
|
|
2594
|
+
const brain = this._brain(group, name, provider) // 可能抛:要在挂句柄之前
|
|
2595
|
+
const controller = new AbortController()
|
|
2596
|
+
const rec = { controller, sid, provider: name, boundary: null, startedAt: Date.now(), why: '', lateKill: null, exited: null, skip: false, closed: false }
|
|
2597
|
+
rt.v2Compacting = rec
|
|
2598
|
+
this.log(`group ${chatId}: 空闲整理开始 — 上下文约 ${fmtWan(ctx)},过了整理线 ${fmtWan(plan.at)}`)
|
|
2599
|
+
const cap = setTimeout(() => { if (!rec.why) rec.why = '超时'; controller.abort() }, plan.maxMs)
|
|
2600
|
+
cap.unref?.()
|
|
2601
|
+
rec.exited = (async () => {
|
|
2602
|
+
let release = null, result = null, error = null, spawned = false, verified = false
|
|
2603
|
+
try {
|
|
2604
|
+
if (this.durableV2AcquireCapacity) release = await this.durableV2AcquireCapacity({ signal: controller.signal })
|
|
2605
|
+
if (controller.signal.aborted) {
|
|
2606
|
+
const stopped = new Error('idle compaction stopped before start'); stopped.code = 'IDLE_COMPACT_STOPPED'; throw stopped
|
|
2607
|
+
}
|
|
2608
|
+
// ⚠ 与平时轮次同一组启动参数(工具、MCP、系统提示附录、型号、深度、env)—— 缓存才命中。
|
|
2609
|
+
const input = prepareC2CompactionInput({ appendSystemPrompt: SYSTEM_APPENDIX, chatId, group, provider,
|
|
2610
|
+
config: this.cfg, brain: { model: brain.model, effort: brain.effort }, sessionId: sid,
|
|
2611
|
+
signal: controller.signal, extraEnv: this.baseSkillEnv(chatId), keep: plan.keep, log: this.log,
|
|
2612
|
+
onEvent: event => {
|
|
2613
|
+
if (event.type !== 'system') return
|
|
2614
|
+
if (event.subtype === 'init' && Array.isArray(event.slashCommands)) {
|
|
2615
|
+
rt.v2CanCompact = { provider: name, ok: event.slashCommands.includes('compact') }
|
|
2616
|
+
} else if (compactEvent(event.subtype || '')) {
|
|
2617
|
+
rec.boundary = { pre: event.preTokens || ctx, post: event.postTokens || 0 }
|
|
2618
|
+
}
|
|
2619
|
+
} })
|
|
2620
|
+
const started = this.durableV2CompactSession(input)
|
|
2621
|
+
if (started === null) {
|
|
2622
|
+
rt.v2CanCompact = { provider: name, ok: false }
|
|
2623
|
+
rec.skip = true
|
|
2624
|
+
this.log(`group ${chatId}: 空闲整理 — 这个后端不支持,只在轮内压缩`)
|
|
2625
|
+
return true
|
|
2626
|
+
}
|
|
2627
|
+
spawned = true
|
|
2628
|
+
result = await started
|
|
2629
|
+
verified = this.durableV2VerifyRunnerClosed({ result }) === true
|
|
2630
|
+
} catch (e) {
|
|
2631
|
+
error = e
|
|
2632
|
+
verified = !spawned || this.durableV2VerifyRunnerClosed({ error: e }) === true
|
|
2633
|
+
} finally {
|
|
2634
|
+
clearTimeout(cap)
|
|
2635
|
+
if (rec.lateKill) clearTimeout(rec.lateKill)
|
|
2636
|
+
rec.closed = !spawned || verified
|
|
2637
|
+
if (rec.closed) {
|
|
2638
|
+
try { this._settleIdleCompact(rt, rec, result, error) } catch (e) { this.log(`group ${chatId}: 空闲整理收尾出错(忽略): ${e.message}`) }
|
|
2639
|
+
if (release) { try { release() } catch (e) { this.log(`group ${chatId}: model permit release failed: ${e.message}`) } }
|
|
2640
|
+
if (rt.v2Compacting === rec) rt.v2Compacting = null
|
|
2641
|
+
} else {
|
|
2642
|
+
// Keep the handle as well as the permit: drain, recovery and the next
|
|
2643
|
+
// session writer must all see the same unresolved close evidence.
|
|
2644
|
+
rec.closeError = error || Object.assign(new Error('Compaction close remains unproven'), { code: 'IDLE_COMPACT_CLOSE_UNPROVEN' })
|
|
2645
|
+
this.log(`group ${chatId}: model permit kept until restart; compaction close unproven`)
|
|
2646
|
+
try { this.durableV2Runtime.close() } catch {}
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
return rec.closed
|
|
2650
|
+
})()
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
_settleIdleCompact(rt, rec, result, error) {
|
|
2654
|
+
if (rec.skip) return
|
|
2655
|
+
const chatId = rt.chatId
|
|
2656
|
+
const res = result?.result || null
|
|
2657
|
+
const now = Date.now()
|
|
2658
|
+
const live = this.store.getSession(chatId) === rec.sid
|
|
2659
|
+
const failureText = error ? String(error.message || error) : res?.isError ? String(res.text || res.subtype || '') : ''
|
|
2660
|
+
const quota = res?.quota || (failureText ? classifyError(failureText)?.quota : null)
|
|
2661
|
+
const out = idleCompactSettle({ boundary: rec.boundary, resultError: !!res?.isError, quota: !!quota,
|
|
2662
|
+
error: failureText,
|
|
2663
|
+
aborted: rec.controller.signal.aborted, why: rec.why,
|
|
2664
|
+
fails: rt.v2CompactFails && rt.v2CompactFails.sid === rec.sid ? rt.v2CompactFails.n : 0,
|
|
2665
|
+
resetAt: null, elapsedMs: now - rec.startedAt, now, live })
|
|
2666
|
+
if (out.status === 'compacted' && live) {
|
|
2667
|
+
rt.v2Compacted = true // 下一轮带「进度可能已折叠为摘要」
|
|
2668
|
+
let locked = false
|
|
2669
|
+
try { locked = this.durableV2AssertWriterLock() === true } catch {}
|
|
2670
|
+
const stats = this.store.getSessionStats(chatId)
|
|
2671
|
+
// 群忙(新消息刚停掉整理)时不碰统计:那一轮结算会写新值。
|
|
2672
|
+
if (locked && stats && stats.sessionId === rec.sid && !this._durableV2GroupBusy(chatId)) {
|
|
2673
|
+
const next = { ...stats, compactedAt: now }
|
|
2674
|
+
if (out.ctx) next.ctx = out.ctx; else delete next.ctx
|
|
2675
|
+
this.store.putSessionStats(chatId, next)
|
|
2676
|
+
}
|
|
2677
|
+
}
|
|
2678
|
+
rt.v2CompactFails = { sid: rec.sid, n: out.fails }
|
|
2679
|
+
rt.v2CompactRest = out.restUntil ? { sid: rec.sid, until: out.restUntil } : null
|
|
2680
|
+
this.log(`group ${chatId}: ${out.notice}`)
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
// 同步发出停止信号,返回「进程退干净了」的 Promise(没有在跑 ⇒ null)。已收到压缩边界 ⇒ 不杀(压完了只差落盘),
|
|
2684
|
+
// 兜底 10 s 再杀;否则立刻 abort(与普通轮同一条两段式收尾)。
|
|
2685
|
+
_stopIdleCompact(rt, why, { force = false } = {}) {
|
|
2686
|
+
const rec = rt?.v2Compacting
|
|
2687
|
+
if (!rec) return null
|
|
2688
|
+
if (!rec.why) rec.why = why
|
|
2689
|
+
if (rec.boundary && !force) {
|
|
2690
|
+
if (!rec.lateKill) { rec.lateKill = setTimeout(() => { try { rec.controller.abort() } catch {} }, 10_000); rec.lateKill.unref?.() }
|
|
2691
|
+
} else { try { rec.controller.abort() } catch {} }
|
|
2692
|
+
return rec.exited
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2695
|
+
// 控制台 / 配置写入是同步接口:只能发信号,所以不等边界、立刻杀,收尾认不到会话就不记统计。
|
|
2696
|
+
stopIdleCompact(chatId, why = '会话已重置') { return this._stopIdleCompact(this.rts.get(chatId), why, { force: true }) }
|
|
2697
|
+
|
|
2698
|
+
// 起轮前等整理退出。等待期间挂「等并发名额」的标志与控制器,让这期间到的消息按排队处理;
|
|
2699
|
+
// 关停会中止这个控制器 ⇒ 返回 false,调用方按「派发前被打断」收场。
|
|
2700
|
+
async _awaitIdleCompactStop(rt) {
|
|
2701
|
+
const stopping = this._stopIdleCompact(rt, '新消息')
|
|
2702
|
+
if (!stopping) return true
|
|
2703
|
+
const controller = new AbortController()
|
|
2704
|
+
rt.v2AdmissionController = controller
|
|
2705
|
+
rt.v2AdmissionPending = true
|
|
2706
|
+
let closed = false
|
|
2707
|
+
try { closed = await stopping === true } finally {
|
|
2708
|
+
if (rt.v2AdmissionController === controller) rt.v2AdmissionController = null
|
|
2709
|
+
}
|
|
2710
|
+
if (!closed || controller.signal.aborted) { rt.v2AdmissionPending = false; return false }
|
|
2711
|
+
return true
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2514
2714
|
stop(chatId) {
|
|
2515
2715
|
const rt = this.rts.get(chatId)
|
|
2516
2716
|
return rt?.v2NormalRouteActive ? this.requestDurableV2Stop(chatId) : false
|
package/src/platform/feishu.js
CHANGED
|
@@ -569,22 +569,37 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
|
|
|
569
569
|
// scope) is cached as null so we don't hammer the API, and callers fall back.
|
|
570
570
|
const nameCache = new Map() // openId -> name | null
|
|
571
571
|
let nameScopeWarned = false
|
|
572
|
-
|
|
572
|
+
let memberNameWarned = false
|
|
573
|
+
async function senderNameOf(openId, chatId = null) {
|
|
573
574
|
if (!openId) return null
|
|
574
575
|
if (nameCache.has(openId)) return nameCache.get(openId)
|
|
576
|
+
let name = null
|
|
575
577
|
try {
|
|
576
578
|
const r = await client.contact.user.get({ params: { user_id_type: 'open_id' }, path: { user_id: openId } })
|
|
577
|
-
|
|
578
|
-
nameCache.set(openId, name)
|
|
579
|
-
return name
|
|
579
|
+
name = (r && r.data && r.data.user && (r.data.user.name || r.data.user.nickname)) || null
|
|
580
580
|
} catch (e) {
|
|
581
|
-
nameCache.set(openId, null)
|
|
582
581
|
if (!nameScopeWarned) {
|
|
583
582
|
nameScopeWarned = true
|
|
584
583
|
log('feishu: 拉取用户名失败(需要 contact:contact.base:readonly scope?),发送者标注回退为 open_id — ' + larkErrMsg(e))
|
|
585
584
|
}
|
|
586
|
-
return null
|
|
587
585
|
}
|
|
586
|
+
// 通讯录查不到(缺 scope、或发送者不在应用的通讯录范围内 —— 基础版多人群常见)时,退到当前群的
|
|
587
|
+
// 成员列表:名字随成员一起返回,顺带把同群其他人也写进同一份缓存。
|
|
588
|
+
if (!name && chatId) {
|
|
589
|
+
try {
|
|
590
|
+
for (const m of await fetchChatMembers(client, chatId)) {
|
|
591
|
+
if (m.name && m.name !== m.openId && !nameCache.get(m.openId)) nameCache.set(m.openId, m.name)
|
|
592
|
+
}
|
|
593
|
+
name = nameCache.get(openId) || null
|
|
594
|
+
} catch (e) {
|
|
595
|
+
if (!memberNameWarned) {
|
|
596
|
+
memberNameWarned = true
|
|
597
|
+
log('feishu: 群成员列表取名失败 — ' + larkErrMsg(e))
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
nameCache.set(openId, name)
|
|
602
|
+
return name
|
|
588
603
|
}
|
|
589
604
|
|
|
590
605
|
async function sendText(chatId, content) {
|
|
@@ -737,7 +752,7 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
|
|
|
737
752
|
let senderName = openId || 'user'
|
|
738
753
|
if (openId) {
|
|
739
754
|
if (nameCache.has(openId)) senderName = nameCache.get(openId) || openId
|
|
740
|
-
else senderNameOf(openId).catch(() => {}) // background warm; does not reorder onMessage
|
|
755
|
+
else senderNameOf(openId, message.chat_id).catch(() => {}) // background warm; does not reorder onMessage
|
|
741
756
|
}
|
|
742
757
|
// Fire-and-forget into the engine (NFR1): a turn can run for minutes,
|
|
743
758
|
// and whether the SDK serializes pending handlers is not contractual —
|