sintbot 1.4.11 → 1.4.13

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/src/c2console.js CHANGED
@@ -8,7 +8,7 @@ const { createDurableConsoleSay } = require('./consolesay')
8
8
 
9
9
  // A host facade over existing v2 authorities, not a second execution engine.
10
10
  // The allowlist applies after the existing console's authentication boundary.
11
- const READ_PATHS = ['/api/state', '/api/events', '/api/inbox', '/api/activity',
11
+ const READ_PATHS = ['/api/state', '/api/events', '/api/inbox', '/api/activity', '/api/activity/detail',
12
12
  '/api/changelog', '/api/changelog-en', '/api/codex/models', '/api/usage',
13
13
  '/api/plugins', '/api/design/styles', '/api/design/icons', '/api/fs/listdir',
14
14
  '/api/feishu/admin-candidates', '/api/feishu/org-users', '/api/feishu/resolve-contact',
@@ -30,7 +30,7 @@ const WRITE_PATHS = ['/api/login', '/api/logout', '/api/logout/all',
30
30
  // one-off agent run); it does not take a group concurrency slot.
31
31
  '/api/test',
32
32
  '/api/inbox/dismiss', '/api/issues/status',
33
- // Console injection, first version: an idle group only, the line shown in
33
+ // Console injection through the ordinary busy-followup ingress, the line shown in
34
34
  // the group and the reply delivered there. The posted line is the platform
35
35
  // message that drives the turn (see consolesay.createDurableConsoleSay).
36
36
  '/api/groups/say',
@@ -38,13 +38,16 @@ const WRITE_PATHS = ['/api/login', '/api/logout', '/api/logout/all',
38
38
  // manages this exact entry (the updater refuses every other run mode).
39
39
  '/api/update/check', '/api/update/start', '/api/update/ignore']
40
40
  const SUPPORTED_RUNNERS = ['claude', 'codex']
41
+ const { modelContext } = require('./modelcontext')
42
+ const { GROUP_COMMANDS } = require('./runtimefacts')
41
43
 
42
- function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld = () => new Map(),
44
+ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld = () => new Map(), activity = null, store = null,
43
45
  assertWriterLock, validateConfig, applyConfig, beforeWrite = () => true,
44
46
  appliedResets = () => [], now = Date.now,
45
47
  // Console injection reaches a group only through the service's own ingress
46
48
  // and that group's real platform adapter. Without them it is refused.
47
49
  getPlatform = null, routeMessage = null, isServing = () => false, isFrozen = () => false,
50
+ acquireCapacity = null,
48
51
  log = () => {} } = {}) {
49
52
  for (const callback of [getBindings, getCapacity, getConfig, getHeld, assertWriterLock,
50
53
  validateConfig, applyConfig, beforeWrite, appliedResets, now, isServing, isFrozen, log]) {
@@ -58,9 +61,12 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
58
61
  throw new TypeError('C2 console configuration hooks must be synchronous')
59
62
  }
60
63
  const startedAt = now()
64
+ const trials = require('./consoletrials').createConsoleTrials({ getCapacity, acquireCapacity,
65
+ assertWriterLock, isServing, isFrozen })
61
66
  const capabilities = Object.freeze({ mode: 'durable-v2',
67
+ groupCommands: GROUP_COMMANDS,
62
68
  reason: '此操作尚未接入当前版本的持久处理流程,未执行。',
63
- summary: '实时状态、认证管理、停止任务、空闲群开新会话、空闲群的控制台插话、绑定新群、空闲群的模型切换(Codex / Claude Code)和项目目录修改、模型的添加与设置,以及后台运行时的软件升级已接入;该群有任务或未结记录时不能修改,也不能开新会话或插话;改模型的型号、后端或端点时,用它的群须空闲,并会开始新的一段对话。插话会把这句话(标着「管理员」)和回复都发进群,群忙时不排队、也不加入当前任务;静默插话和不回复的插话暂不支持。解除绑定、移除模型、定时任务和画布暂不可用。',
69
+ summary: '实时状态、认证管理、停止任务、空闲群开新会话、支持忙时跟进的控制台插话、绑定新群、空闲群的模型切换(Codex / Claude Code)和项目目录修改、模型的添加与设置,以及后台运行时的软件升级已接入;该群有任务或未结记录时不能修改,也不能开新会话;改模型的型号、后端或端点时,用它的群须空闲,并会开始新的一段对话。插话会把这句话(标着「管理员」)和回复都发进群,群忙时按该群的忙时跟进设置尝试加入当前任务,关闭或暂不支持时排队;确认模型读取后才显示已加入;静默插话和不回复的插话暂不支持。解除绑定、移除模型、定时任务和画布暂不可用。',
64
70
  allowed: { GET: READ_PATHS, POST: WRITE_PATHS } })
65
71
  function held() {
66
72
  if (assertWriterLock() !== true) throw new Error('服务写入权限已失效,操作未执行')
@@ -76,7 +82,7 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
76
82
  // flight keeps the current session.
77
83
  function groupBusy(chatId, binding) {
78
84
  const rt = binding.engine?.rts?.get(chatId)
79
- const snapshot = binding.journal?.snapshot()
85
+ const snapshot = binding.journal?.consoleSnapshot?.() || binding.journal?.snapshot()
80
86
  return !!(rt?.v2NormalRouteActive || rt?.v2AdmissionPending || rt?.v2Controller ||
81
87
  rt?.v2QueueReceiptIds?.length || snapshot?.receipts?.pending?.length ||
82
88
  snapshot?.state?.lanes?.queue?.[chatId]?.queue?.length ||
@@ -97,7 +103,7 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
97
103
  binding.engine.store.resetSessionAndStats(chatId)
98
104
  return true
99
105
  }
100
- // Console injection, first version: an open, idle group only. Checked
106
+ // Console injection: an open group, idle or running. Checked
101
107
  // before the line is posted, so a refused group shows nothing, and again
102
108
  // right after; the service ingress remains the final authority.
103
109
  function sayGate(chatId) {
@@ -110,8 +116,13 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
110
116
  const binding = bindings().get(chatId)
111
117
  if (!binding || binding.runtime?.closed === true) return 'not-serving'
112
118
  const rt = binding.engine?.rts?.get(chatId)
113
- if (groupBusy(chatId, binding) || rt?.v2RunningJobs || rt?.v2StopControl || rt?.v2SkipWaiter ||
114
- binding.journal?.snapshot()?.state?.lanes?.stopIntent?.[chatId]) return 'busy'
119
+ const snapshot = binding.journal?.consoleSnapshot?.() || binding.journal?.snapshot()
120
+ if (rt?.v2StopControl || rt?.v2SkipWaiter || snapshot?.state?.lanes?.stopIntent?.[chatId]) return 'busy'
121
+ // A normal active turn may accept ordinary messages. Unowned pending work
122
+ // still refuses; it must not become a new execution path or bypass recovery.
123
+ if (!rt?.v2NormalRouteActive && !rt?.v2AdmissionPending && (groupBusy(chatId, binding) || rt?.v2RunningJobs)) return 'busy'
124
+ const queued = snapshot?.state?.lanes?.queue?.[chatId]?.queue?.length || 0
125
+ if (queued >= (rt?.v2AdmissionPending ? 3 : 2)) return 'queue-full'
115
126
  return null
116
127
  }
117
128
  function receiptState(chatId, receiptId) {
@@ -120,9 +131,22 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
120
131
  if (receipts?.completed?.some(item => item.id === receiptId)) return 'completed'
121
132
  return null
122
133
  }
134
+ function locateSay(chatId, receiptId) {
135
+ const binding = bindings().get(chatId)
136
+ const rt = binding?.engine?.rts?.get(chatId)
137
+ const state = receiptState(chatId, receiptId)
138
+ if (state === 'completed') return { state: 'done' }
139
+ const steer = rt?.v2SteerEntries?.get(receiptId)
140
+ if (steer) return { state: steer.accepted && steer.landed ? 'joined' : 'following' }
141
+ const queue = (binding?.journal?.consoleSnapshot?.() || binding?.journal?.snapshot())?.state?.lanes?.queue?.[chatId]?.queue || []
142
+ const index = queue.findIndex(item => item.id === `receipt:job:${receiptId}`)
143
+ if (index >= 0) return { state: 'queued', ahead: index, join: false }
144
+ if (rt?.v2RunningJobs?.some(job => job.msg?._c2ReceiptId === receiptId)) return { state: 'started' }
145
+ return { state: 'accepted' }
146
+ }
123
147
  const say = getPlatform ? createDurableConsoleSay({ getPlatform,
124
148
  getGroup: chatId => getConfig().groups?.[chatId] || null,
125
- gate: sayGate, route: routeMessage, receiptState, log, now }) : null
149
+ gate: sayGate, route: routeMessage, receiptState, locate: locateSay, log, now }) : null
126
150
  function getLiveState() {
127
151
  const cfg = getConfig()
128
152
  const capacity = getCapacity()
@@ -133,11 +157,13 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
133
157
  const binding = bindings().get(chatId)
134
158
  const hold = heldGroups.get(chatId)
135
159
  const rt = binding?.engine?.rts.get(chatId)
136
- const snapshot = binding?.journal?.snapshot()
160
+ const snapshot = binding?.journal?.consoleSnapshot?.() || binding?.journal?.snapshot()
137
161
  const pending = snapshot?.receipts?.pending || []
138
162
  const queued = snapshot?.state?.lanes?.queue?.[chatId]?.queue?.length || 0
139
163
  queue += queued
140
164
  groups[chatId] = {
165
+ context: activity?.context(chatId, (binding?.engine?.store || store)?.getSession(chatId),
166
+ !!rt?.v2NormalRouteActive, rt?.v2RunnerGeneration) || null,
141
167
  busy: !!rt?.v2NormalRouteActive,
142
168
  finalizing: !!rt?.v2TerminalStarted,
143
169
  followups: !!rt?.v2SteerControl && !rt?.v2SteerClosing && !rt?.v2TerminalStarted,
@@ -145,7 +171,7 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
145
171
  queueFaulted: !group.paused && (!binding || (!rt?.v2NormalRouteActive && !!binding.gate?.blocked)),
146
172
  waitingForCapacity: !!rt?.v2AdmissionPending,
147
173
  sinceMs: rt?.v2StartedAt ? Math.max(0, now() - rt.v2StartedAt) : 0,
148
- sessionId: binding?.engine?.store?.getSession(chatId) || '',
174
+ sessionId: (binding?.engine?.store || store)?.getSession(chatId) || '',
149
175
  // Why a console injection would be refused right now (null = accepted).
150
176
  sayBlocked: say ? sayGate(chatId) : 'not-serving',
151
177
  // A held group needs the operator: its last result is unknown.
@@ -163,29 +189,56 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
163
189
  function getActivity() {
164
190
  const cfg = getConfig(), items = []
165
191
  for (const [chatId, binding] of bindings()) {
166
- const snap = binding.journal.snapshot()
192
+ const snap = binding.journal.consoleSnapshot?.() || binding.journal.snapshot()
167
193
  const rt = binding.engine.rts.get(chatId)
168
194
  const group = cfg.groups[chatId]?.name || chatId.slice(-8)
169
195
  for (const receipt of snap.receipts.pending) {
196
+ if (activity?.has(chatId, receipt.id)) continue
170
197
  const running = rt?.v2RunningJobs?.some(job => job.msg?._c2ReceiptId === receipt.id)
171
198
  items.push({ id: receipt.id, chatId, group, ts: receipt.acceptedAt,
172
199
  kind: running ? 'start' : 'done', status: running ? 'running' : '等待处理',
173
200
  text: running ? '正在处理群消息' : '消息已登记,等待处理', sender: '' })
174
201
  }
175
202
  for (const receipt of snap.receipts.completed.slice(-30)) {
203
+ if (activity?.has(chatId, receipt.id)) continue
176
204
  // A settled receipt alone does not prove successful answer delivery.
177
205
  items.push({ id: receipt.id, chatId, group, ts: receipt.settledAt,
178
206
  kind: 'done', status: '已结账', text: '消息处理记录已结账', sender: '' })
179
207
  }
180
208
  }
181
- return items.sort((a, b) => b.ts - a.ts).slice(0, 30)
209
+ const recent = activity?.list({ limit: 100 }).activity || []
210
+ return [...recent, ...items].filter(item => cfg.groups[item.chatId])
211
+ .sort((a, b) => Number(a.kind !== 'start') - Number(b.kind !== 'start') || b.ts - a.ts).slice(0, 100)
212
+ }
213
+ function activityPage(query = {}) {
214
+ const cfg = getConfig(), live = getLiveState()
215
+ const meta = activity?.list({ limit: 1 }) || { epoch: 'receipts', revision: 0 }
216
+ const groups = Object.entries(cfg.groups || {}).map(([chatId, group]) => {
217
+ const binding = bindings().get(chatId), groupStore = binding?.engine?.store || store
218
+ const providerName = groupStore?.getProvider?.(chatId) || group.provider || cfg.defaultProvider
219
+ const provider = cfg.providers?.[providerName] || {}
220
+ const stats = groupStore?.getSessionStats?.(chatId), session = groupStore?.getSession?.(chatId)
221
+ const context = live.groups[chatId].context || (session && stats?.sessionId === session && Number.isFinite(stats.ctx)
222
+ ? { usedTokens: stats.ctx, windowTokens: null, measuredAt: null, quality: 'historical' } : null)
223
+ return { chatId, name: group.name || chatId.slice(-8), project: path.basename(group.workdir || '') || '—',
224
+ workdir: group.workdir || '', runner: provider.runner || 'claude',
225
+ ...modelContext(group, provider),
226
+ reasoningEffort: (provider.runner === 'codex' ? provider.reasoningEffort : provider.env?.CLAUDE_CODE_EFFORT_LEVEL) || '',
227
+ paused: !!group.paused, ...live.groups[chatId], context }
228
+ })
229
+ const rows = getActivity().filter(row => (!query.chatId || row.chatId === query.chatId) && (!query.status || row.status === query.status))
230
+ const offset = Math.max(0, Math.min(100, Number(query.offset) || 0)), limit = Math.max(1, Math.min(100, Number(query.limit) || 30))
231
+ return { epoch: meta.epoch, revision: meta.revision, storageError: !!meta.storageError, groups,
232
+ total: rows.length, activity: rows.slice(offset, offset + limit) }
182
233
  }
183
234
  return {
184
235
  capabilities,
236
+ beginTrial: controller => trials.begin(controller),
185
237
  allows: (method, pathname) => (capabilities.allowed[method] || []).includes(pathname),
186
238
  // A provider without a runner field is Claude Code, as everywhere else.
187
239
  providerSupported: provider => SUPPORTED_RUNNERS.includes(provider?.runner || 'claude'),
188
- getLiveState, getActivity,
240
+ getLiveState, getActivity, activityPage,
241
+ activityDetail(chatId, id) { return getConfig().groups?.[chatId] ? activity?.detail(chatId, id) || null : null },
189
242
  validateConfig(raw) { held(); validateConfig(raw) },
190
243
  beforeWrite(raw) { held(); beforeWrite(raw) },
191
244
  applyConfig(raw) { held(); applyConfig(raw) },
@@ -193,6 +246,12 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
193
246
  appliedResets: () => appliedResets(),
194
247
  decorateState(state) {
195
248
  state.runtimeCapabilities = capabilities
249
+ for (const group of state.groups || []) {
250
+ const cfg = getConfig(), source = cfg.groups?.[group.chatId] || {}
251
+ const provider = cfg.providers?.[group.effectiveProvider || source.provider || cfg.defaultProvider]
252
+ const model = modelContext(source, provider)
253
+ Object.assign(group, { configuredModel: model.model, modelSource: model.source, actualModel: model.actualModel })
254
+ }
196
255
  state.previews = (state.previews || []).filter(item => getConfig().groups?.[item.chatId])
197
256
  state.leftovers = { supported: false, rows: [], error: null }
198
257
  },
@@ -241,7 +300,18 @@ function createC2Console(options = {}) {
241
300
  inboxDismissPath: path.join(stateDir, 'inbox-dismissed.json'),
242
301
  verifyFile: path.join(stateDir, 'verify.json'),
243
302
  })
244
- return { ...ui, facade }
303
+ let previous = null
304
+ const unsubscribe = options.activity?.subscribe(() => {
305
+ const page = facade.activityPage({ limit: 100 })
306
+ const before = new Map((previous?.activity || []).map(row => [row.chatId + ':' + row.id, JSON.stringify(row)]))
307
+ const current = new Set(page.activity.map(row => row.chatId + ':' + row.id))
308
+ const changed = page.activity.filter(row => before.get(row.chatId + ':' + row.id) !== JSON.stringify(row))
309
+ const groups = page.groups.filter(group => JSON.stringify(previous?.groups.find(g => g.chatId === group.chatId)) !== JSON.stringify(group))
310
+ ui.pushEvent('activity-update', { epoch: page.epoch, revision: page.revision, storageError: page.storageError,
311
+ activity: changed, groups, removed: [...before.keys()].filter(id => !current.has(id)) })
312
+ previous = page
313
+ })
314
+ return { ...ui, facade, close() { unsubscribe?.(); ui.close() } }
245
315
  }
246
316
 
247
317
  module.exports = { createC2Console, createC2ConsoleFacade }
package/src/c2updater.js CHANGED
@@ -289,16 +289,24 @@ function createC2Updater({ currentVersion, runMode, stateFile, dataDir, configPa
289
289
  const blocked = gate()
290
290
  if (blocked.includes('held')) return fail('有暂停的群,升级已取消;先处理暂停的群')
291
291
  if (blocked.length) { schedule(); return status() }
292
- const prefix = path.join(releasesRoot, job.to)
292
+ let prefix = job.installed ? job.prefix : path.join(releasesRoot, job.to)
293
293
  if (!job.installed) {
294
294
  setJob({ state: 'installing', prefix }, `正在安装 v${job.to}`)
295
295
  try {
296
+ let reusable = false
296
297
  if (fs.existsSync(prefix)) {
297
- // A directory left by an earlier attempt or a manual install is reused
298
- // only when it verifies as exactly this version.
299
- await verifyInstall({ version: job.to, prefix })
300
- } else {
298
+ // Never repair an existing tree in place: another process may still
299
+ // use it. A failed check leaves it intact as evidence.
300
+ try { await verifyInstall({ version: job.to, prefix }); reusable = true }
301
+ catch { /* retry in an independent installation directory */ }
302
+ }
303
+ if (!reusable) {
301
304
  fs.mkdirSync(releasesRoot, { recursive: true, mode: 0o700 })
305
+ // The directory becomes eligible for switching only after validation.
306
+ // Keeping its unique path also avoids overwriting a concurrently
307
+ // installed or running release when a download is retried.
308
+ prefix = fs.mkdtempSync(path.join(releasesRoot, `${job.to}-`))
309
+ setJob({ prefix })
302
310
  await installVersion({ version: job.to, prefix, registry: job.registry })
303
311
  await verifyInstall({ version: job.to, prefix })
304
312
  }
package/src/consolesay.js CHANGED
@@ -86,7 +86,7 @@ function createConsoleSay({ getPlatform, getGroup, handle, locate, log = () => {
86
86
  }
87
87
  }
88
88
 
89
- // ── 持久处理流程(sintbot-v2)的插话,第 1 版:只接「空闲群 · 这句话进群 · 回复进群」 ──
89
+ // ── 持久处理流程(sintbot-v2)的插话,这句话与回复进群,忙时沿用普通群消息的跟进/排队流程 ──
90
90
  //
91
91
  // 群里那行「【管理员】…」就是驱动这一轮的那条平台消息:它的 om_ 消息 id 就是这笔任务的来源身份
92
92
  // (source 'message'),与群成员自己发的一条消息同形 —— 收据、回复锚点、/stop、重启后的中断告知
@@ -97,7 +97,6 @@ function createConsoleSay({ getPlatform, getGroup, handle, locate, log = () => {
97
97
  // 第 1 版不做的,都在发群消息**之前**就拒,群里不留痕:
98
98
  // · 静默插话(这句不进群):没有平台消息可锚,需要另一种来源和重启告知路径;
99
99
  // · 不回复的插话(quiet):持久路径还不接静默收据;
100
- // · 群正忙:不排队、也不加入当前任务;
101
100
  // · 斜杠命令:群命令不走插话(停止、开新会话在概览里有按钮)。
102
101
  //
103
102
  // requestId:控制台每次提交带的 32 位十六进制幂等键(经 syntheticSource 校验并折成键)。发出那行字
@@ -128,9 +127,10 @@ function refusalOf(value) {
128
127
  // route(msg, platform) 服务的统一入口(routeMessage):暂停 / 待人工 / 收尾中 / 升级中都在那里把关
129
128
  // receiptState(chatId, id) 该群日志里这笔收据:'pending' | 'completed' | null
130
129
  function createDurableConsoleSay({ getPlatform, getGroup, gate, route, receiptState,
130
+ locate = () => ({ state: 'accepted' }),
131
131
  log = () => {}, now = () => Date.now(), sendTimeoutMs = SEND_TIMEOUT_MS,
132
132
  confirmMs = CONFIRM_MS, pollMs = POLL_MS, maxRequests = MAX_REQUESTS } = {}) {
133
- for (const hook of [getPlatform, getGroup, gate, route, receiptState, log, now]) {
133
+ for (const hook of [getPlatform, getGroup, gate, route, receiptState, locate, log, now]) {
134
134
  if (typeof hook !== 'function') throw new TypeError('durable console say needs explicit hooks')
135
135
  }
136
136
  const posted = new Map() // 请求键 → 群里那行字的消息 id(按插入顺序,超过上限丢最早的)
@@ -171,7 +171,7 @@ function createDurableConsoleSay({ getPlatform, getGroup, gate, route, receiptSt
171
171
  return { ok: false, reason: 'shown-failed' }
172
172
  }
173
173
  remember(key, shownId)
174
- // 发这行字的这会儿群里可能刚开始了别的任务:此时不注入(第 1 版忙时不排队、不加入)
174
+ // 发送期间再次检查暂停、停止、维护及容量,正常忙时仍走统一入口
175
175
  const late = gate(chatId)
176
176
  if (late) return { ok: false, reason: late === 'busy' ? 'busy-after-post' : late }
177
177
  }
@@ -186,12 +186,12 @@ function createDurableConsoleSay({ getPlatform, getGroup, gate, route, receiptSt
186
186
  if (first.admitted || first.timeout) {
187
187
  // 这一轮在后台继续;它之后的成败由该群自己的持久路径收尾,这里只留日志
188
188
  settled.then(({ error }) => { if (error) log('say: ' + (error && error.message)) })
189
- return first.admitted ? { ok: true, where: { state: 'started' } } : { ok: false, reason: 'unconfirmed' }
189
+ return first.admitted ? { ok: true, where: locate(chatId, receiptId) } : { ok: false, reason: 'unconfirmed' }
190
190
  }
191
191
  const { value, error } = first
192
192
  if (value && value.duplicate) return { ok: true, where: { state: 'duplicate' } }
193
193
  const state = receiptState(chatId, receiptId)
194
- if (state) return { ok: true, where: { state: state === 'completed' ? 'done' : 'started' } }
194
+ if (state) return { ok: true, where: state === 'completed' ? { state: 'done' } : locate(chatId, receiptId) }
195
195
  if (error) {
196
196
  log('say: ' + (error && error.message))
197
197
  return { ok: false, reason: 'inject-failed' }
@@ -0,0 +1,43 @@
1
+ 'use strict'
2
+
3
+ // One bounded console trial, sharing the real model capacity. A timeout or
4
+ // lease loss asks the runner to stop; only its close proof releases capacity.
5
+ function createConsoleTrials({ getCapacity, acquireCapacity, assertWriterLock, isServing, isFrozen }) {
6
+ let active = false
7
+ function assertWritable() {
8
+ if (assertWriterLock() !== true) throw new Error('服务写入权限已失效,试运行结果未保存')
9
+ }
10
+ function assertAdmission() {
11
+ assertWritable()
12
+ if (!isServing() || isFrozen()) throw new Error('服务未就绪或正在维护,暂不能试运行')
13
+ }
14
+ async function begin(controller) {
15
+ assertAdmission()
16
+ const capacity = getCapacity()
17
+ if (active || capacity.closed || capacity.active + capacity.waiting >= capacity.maxConcurrent) {
18
+ throw new Error('试运行或模型并发名额已满,请稍后重试')
19
+ }
20
+ if (typeof acquireCapacity !== 'function') throw new Error('当前服务未接入试运行并发准入')
21
+ active = true
22
+ let release
23
+ try {
24
+ release = await acquireCapacity({ signal: controller.signal })
25
+ assertAdmission()
26
+ } catch (error) { active = false; release?.(); throw error }
27
+ let finished = false
28
+ const monitor = setInterval(() => {
29
+ try { assertAdmission() } catch (error) { controller.abort(error) }
30
+ }, 100)
31
+ monitor.unref?.()
32
+ return { assertWritable,
33
+ finish(closed) {
34
+ if (finished) return
35
+ finished = true; clearInterval(monitor)
36
+ // Unknown closure keeps the shared slot occupied and blocks upgrades.
37
+ if (closed) { active = false; release() }
38
+ },
39
+ }
40
+ }
41
+ return { begin, active: () => active }
42
+ }
43
+ module.exports = { createConsoleTrials }
@@ -129,7 +129,7 @@ function prepareCardTerminalPath({ runtime, payloadHandles, platform, replyCtx,
129
129
  const nextCtx = landedAnchor(anchor)
130
130
  if (phase !== 'open' || !card) return false
131
131
  if (activeAnchorCtx.messageId === nextCtx.messageId) return true
132
- if (!lastCardText) throw new Error('C2 card move has no visible text to replay')
132
+ if (!lastCardText) throw new Error('C2 card move has no visible text to preserve')
133
133
  phase = 'relocating'
134
134
  try {
135
135
  if (await card.flush() !== true) throw new Error('C2 old card flush unconfirmed')
@@ -138,6 +138,7 @@ function prepareCardTerminalPath({ runtime, payloadHandles, platform, replyCtx,
138
138
  const fresh = await platform.createStreamingCard(nextCtx, {
139
139
  operationKey: `${receiptId}:followup:${anchor.receiptId}`,
140
140
  operationBoundary,
141
+ initialText: lastCardText,
141
142
  })
142
143
  if (!fresh || typeof fresh.update !== 'function' ||
143
144
  typeof fresh.flush !== 'function' || typeof fresh.recall !== 'function') {
@@ -646,6 +646,16 @@ class C2DurableJournal {
646
646
  effects: new Map(this.#effects), notices: new Map(this.#notices) }
647
647
  }
648
648
 
649
+ // Bounded diagnostics: the console must not clone all effects and history
650
+ // at progress-event frequency. This is never accepted as recovery authority.
651
+ consoleSnapshot() {
652
+ return { cursor: this.#cursor, state: { lanes: {
653
+ queue: structuredClone(this.#state.lanes?.queue || {}),
654
+ stopIntent: structuredClone(this.#state.lanes?.stopIntent || {}) } },
655
+ receipts: { pending: structuredClone(this.#receipts.pending),
656
+ completed: structuredClone(this.#receipts.completed.slice(-30)) } }
657
+ }
658
+
649
659
  #settlementAuthority() {
650
660
  return { state: this.#state, receipts: this.#receipts, effects: this.#effects,
651
661
  attempts: this.#effectAttempts, descriptors: this.#effectDescriptors,