sintbot 1.4.0 → 1.4.2

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
@@ -17,12 +17,15 @@ const WRITE_PATHS = ['/api/login', '/api/logout', '/api/logout/all',
17
17
  '/api/groups/order', '/api/groups/policy', '/api/groups/redact',
18
18
  '/api/groups/codex-steer', '/api/groups/pause', '/api/groups/stop',
19
19
  '/api/agent', '/api/provider-settings', '/api/codex/verify', '/api/usage/refresh',
20
- '/api/inbox/dismiss', '/api/issues/status']
20
+ '/api/inbox/dismiss', '/api/issues/status',
21
+ // Software updates: check and ignore anywhere; install only when launchd
22
+ // manages this exact entry (the updater refuses every other run mode).
23
+ '/api/update/check', '/api/update/start', '/api/update/ignore']
21
24
  const SUPPORTED_RUNNERS = ['claude', 'codex']
22
25
 
23
- function createC2ConsoleFacade({ getBindings, getCapacity, getConfig,
26
+ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld = () => new Map(),
24
27
  assertWriterLock, validateConfig, applyConfig, beforeWrite = () => true, now = Date.now } = {}) {
25
- for (const callback of [getBindings, getCapacity, getConfig, assertWriterLock,
28
+ for (const callback of [getBindings, getCapacity, getConfig, getHeld, assertWriterLock,
26
29
  validateConfig, applyConfig, beforeWrite, now]) {
27
30
  if (typeof callback !== 'function') throw new TypeError('C2 console needs live authority and configuration hooks')
28
31
  }
@@ -32,7 +35,7 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig,
32
35
  const startedAt = now()
33
36
  const capabilities = Object.freeze({ mode: 'durable-v2',
34
37
  reason: '此操作尚未接入当前版本的持久处理流程,未执行。',
35
- summary: '实时状态、认证管理、停止任务、绑定新群,以及空闲群的模型切换(Codex / Claude Code)和项目目录修改已接入;该群有任务或未结记录时不能修改。开新会话、解除绑定、控制台插话、定时任务、画布和升级暂不可用。',
38
+ summary: '实时状态、认证管理、停止任务、绑定新群、空闲群的模型切换(Codex / Claude Code)和项目目录修改,以及后台运行时的软件升级已接入;该群有任务或未结记录时不能修改。开新会话、解除绑定、控制台插话、定时任务和画布暂不可用。',
36
39
  allowed: { GET: READ_PATHS, POST: WRITE_PATHS } })
37
40
  function held() {
38
41
  if (assertWriterLock() !== true) throw new Error('服务写入权限已失效,操作未执行')
@@ -45,10 +48,12 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig,
45
48
  function getLiveState() {
46
49
  const cfg = getConfig()
47
50
  const capacity = getCapacity()
51
+ const heldGroups = getHeld()
48
52
  const groups = {}
49
53
  let queue = 0
50
54
  for (const [chatId, group] of Object.entries(cfg.groups || {})) {
51
55
  const binding = bindings().get(chatId)
56
+ const hold = heldGroups.get(chatId)
52
57
  const rt = binding?.engine?.rts.get(chatId)
53
58
  const snapshot = binding?.journal?.snapshot()
54
59
  const pending = snapshot?.receipts?.pending || []
@@ -63,14 +68,17 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig,
63
68
  waitingForCapacity: !!rt?.v2AdmissionPending,
64
69
  sinceMs: rt?.v2StartedAt ? Math.max(0, now() - rt.v2StartedAt) : 0,
65
70
  sessionId: binding?.engine?.store?.getSession(chatId) || '',
71
+ // A held group needs the operator: its last result is unknown.
72
+ ...(hold ? { held: true, heldReason: hold.reason, heldSinceMs: Math.max(0, now() - hold.since) } : {}),
66
73
  }
67
74
  }
68
75
  const activeBindingsReady = Object.entries(cfg.groups || {})
69
- .filter(([, group]) => !group.paused).every(([chatId]) => bindings().has(chatId))
76
+ .filter(([chatId, group]) => !group.paused && !heldGroups.has(chatId))
77
+ .every(([chatId]) => bindings().has(chatId))
70
78
  return { running: assertWriterLock() === true && !capacity.closed && activeBindingsReady,
71
79
  uptimeMs: Math.max(0, now() - startedAt),
72
80
  concurrency: { active: capacity.active, max: capacity.maxConcurrent },
73
- queue, waiting: capacity.waiting, groups }
81
+ queue, waiting: capacity.waiting, held: heldGroups.size, groups }
74
82
  }
75
83
  function getActivity() {
76
84
  const cfg = getConfig(), items = []
@@ -128,7 +136,7 @@ function createC2Console(options = {}) {
128
136
  throw new TypeError('C2 console requires explicit installation paths')
129
137
  }
130
138
  const facade = createC2ConsoleFacade(options)
131
- const ui = createUiServer({ configPath, host, log, getConnHealth,
139
+ const ui = createUiServer({ configPath, host, log, getConnHealth, updater: options.updater || null,
132
140
  onHostChange: options.onHostChange,
133
141
  getLiveState: facade.getLiveState, control: facade.control, runtimePolicy: facade,
134
142
  issuesPath: path.join(stateDir, 'issues.jsonl'),
@@ -0,0 +1,125 @@
1
+ 'use strict'
2
+
3
+ // Operator tool for a group the service holds after an unresolved fault.
4
+ // With the service stopped and the writer lock held, it moves that group's
5
+ // journal unchanged under <data>/incidents/, records the source messages it
6
+ // left unsettled and the receipts it had settled, then gives the group a new
7
+ // empty journal under the same mapped name. Nothing is settled, rewritten,
8
+ // replayed or notified; every later start refuses those messages from
9
+ // incident.json (see loadC2IncidentQuarantine).
10
+ const fs = require('node:fs')
11
+ const path = require('node:path')
12
+ const { createHash } = require('node:crypto')
13
+ const { C2DurableJournal } = require('./core/c2durablejournal')
14
+ const { assertJournalScope, assertPrivateDirectory, VALIDATORS } = require('./c2install')
15
+
16
+ const GROUP_ID = /^oc_[A-Za-z0-9_-]{1,124}$/
17
+ const FEISHU_MESSAGE_ID = /^om_[A-Za-z0-9_-]{1,124}$/
18
+ const SCHEMA = 'sbot-c2-incident-seal-v1'
19
+
20
+ const sha256 = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex')
21
+ function privateFile(file, label) {
22
+ const st = fs.lstatSync(file)
23
+ if (!st.isFile() || st.isSymbolicLink() ||
24
+ typeof process.getuid === 'function' && st.uid !== process.getuid() ||
25
+ process.platform !== 'win32' && (st.mode & 0o077)) {
26
+ throw new Error(`${label} is not a private regular file owned by this user; preserve it and review`)
27
+ }
28
+ return st
29
+ }
30
+ function writeNew(file, value) {
31
+ const fd = fs.openSync(file, 'wx', 0o600)
32
+ try { fs.writeSync(fd, JSON.stringify(value, null, 2) + '\n'); fs.fsyncSync(fd) }
33
+ finally { fs.closeSync(fd) }
34
+ }
35
+ function fsyncDir(dir) {
36
+ const fd = fs.openSync(dir, 'r')
37
+ try { fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
38
+ }
39
+ const pad = n => String(n).padStart(2, '0')
40
+ function stamp(ms) {
41
+ const d = new Date(ms)
42
+ return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
43
+ }
44
+
45
+ function sealC2GroupIncident({ installation, chatId, reason = 'operator sealed a held group',
46
+ now = Date.now, acquireLock = require('./core/instancelock').acquire, version = '' } = {}) {
47
+ if (typeof chatId !== 'string' || !GROUP_ID.test(chatId)) {
48
+ throw new Error('Name the held group with --group oc_…')
49
+ }
50
+ if (typeof reason !== 'string' || !reason.trim() || reason.length > 500) {
51
+ throw new Error('The incident reason must be 1 to 500 characters')
52
+ }
53
+ const { stateDir, routes } = installation || {}
54
+ const route = routes?.find(item => item.chatId === chatId)
55
+ if (!route) throw new Error('This group is not in the configuration')
56
+ if ((installation.addedRoutes || []).some(item => item.chatId === chatId)) {
57
+ throw new Error('This group has no journal yet; nothing to seal')
58
+ }
59
+ assertPrivateDirectory(stateDir)
60
+ const lockFile = path.join(stateDir, 'daemon.lock')
61
+ const lock = acquireLock(lockFile)
62
+ if (!lock?.ok) throw new Error('The service is running; stop it before sealing an incident')
63
+ const held = () => {
64
+ try { return fs.readFileSync(lockFile, 'utf8').trim() === String(process.pid) } catch { return false }
65
+ }
66
+ try {
67
+ const file = route.file
68
+ const stat = privateFile(file, 'The group journal')
69
+ const before = { sha256: sha256(file), bytes: stat.size }
70
+ let journal
71
+ try { journal = C2DurableJournal.open(file, VALIDATORS) } catch (error) {
72
+ throw new Error(`The group journal cannot be read (${error.message}); preserve it unchanged and review it manually`)
73
+ }
74
+ const snapshot = assertJournalScope(journal, chatId)
75
+ let settled = true
76
+ try { assertJournalScope(journal, chatId, { requireSettled: true }) } catch { settled = false }
77
+ if (settled) throw new Error('This group journal is settled; nothing to seal. Start the service normally')
78
+ if (sha256(file) !== before.sha256) {
79
+ throw new Error('Opening the group journal changed it; preserve every file for review')
80
+ }
81
+ const pending = snapshot.receipts.pending
82
+ if (pending.some(item => item.source !== 'message' || !FEISHU_MESSAGE_ID.test(item.sourceId))) {
83
+ throw new Error('The journal holds a receipt that is not a Feishu message; review it manually')
84
+ }
85
+ const incidents = path.join(stateDir, 'incidents')
86
+ fs.mkdirSync(incidents, { recursive: true, mode: 0o700 })
87
+ fs.chmodSync(incidents, 0o700)
88
+ const sealedAt = now()
89
+ const dir = path.join(incidents, `${stamp(sealedAt)}-group-${chatId.slice(-8)}`)
90
+ fs.mkdirSync(dir, { mode: 0o700 })
91
+ writeNew(path.join(dir, 'incident.json'), { schema: SCHEMA, chatId,
92
+ journal: path.basename(file), sha256: before.sha256, bytes: before.bytes,
93
+ cursor: snapshot.cursor,
94
+ pending: pending.map(item => ({ id: item.id, sourceId: item.sourceId })),
95
+ completedReceiptIds: snapshot.receipts.completed.map(item => item.id),
96
+ sealedAt, reason: reason.trim(),
97
+ handling: 'frozen unchanged; receipts not settled; turn not replayed; source messages refused at admission from incident.json',
98
+ tool: 'sintbot incident seal', version })
99
+ if (!held()) throw new Error('The writer lock was lost before sealing; preserve every file for review')
100
+ const sealed = path.join(dir, path.basename(file))
101
+ fs.renameSync(file, sealed)
102
+ fsyncDir(stateDir); fsyncDir(dir)
103
+ if (sha256(sealed) !== before.sha256 || fs.existsSync(file)) {
104
+ throw new Error('The sealed journal differs; preserve every file for review')
105
+ }
106
+ if (!held()) throw new Error('The writer lock was lost before the new journal; preserve every file for review')
107
+ C2DurableJournal.initialize(file, { state: { version: 2, seq: 0, lanes: {} },
108
+ receipts: { pending: [], completed: [] } }, VALIDATORS)
109
+ fs.chmodSync(file, 0o600)
110
+ const fresh = C2DurableJournal.open(file, VALIDATORS)
111
+ const empty = assertJournalScope(fresh, chatId, { requireSettled: true })
112
+ if (fresh.blocked || empty.cursor !== 0 || empty.eventCount !== 0 ||
113
+ empty.receipts.pending.length || empty.receipts.completed.length ||
114
+ empty.effects.size || empty.notices.size || Object.keys(empty.state.lanes).length) {
115
+ throw new Error('New group journal verification failed; preserve every file for review')
116
+ }
117
+ const result = Object.freeze({ incident: dir, sealed, sealedSha256: before.sha256,
118
+ newJournal: file, newJournalSha256: sha256(file),
119
+ refusedSources: pending.length, settledRemembered: snapshot.receipts.completed.length })
120
+ writeNew(path.join(dir, 'result.json'), result)
121
+ return result
122
+ } finally { lock.release() }
123
+ }
124
+
125
+ module.exports = { sealC2GroupIncident }
package/src/c2install.js CHANGED
@@ -201,7 +201,7 @@ function assertJournalScope(journal, chatId, { requireSettled = false } = {}) {
201
201
  Object.values(snap.state.lanes.queue || {}).some(lane => lane.queue.length) ||
202
202
  Object.values(snap.state.lanes.session || {}).some(lane => lane.settled !== true) ||
203
203
  ['terminal', 'stopIntent', 'cardCleanup', 'steer'].some(domain => Object.keys(snap.state.lanes[domain] || {}).length))) {
204
- throw new Error('Paused group journal has unresolved work; preserve it and explicitly review recovery')
204
+ throw new Error('Group journal has unresolved work; preserve it and explicitly review recovery')
205
205
  }
206
206
  return snap
207
207
  }
@@ -4,6 +4,9 @@ const { isC2InputRejection, answerC2InputRejection, rejectC2Input } = require('.
4
4
  const { coordinateC2GlobalFault } = require('./core/c2globalfault')
5
5
 
6
6
  const RECOVERING_NOTICE = '上一个任务出错,正在收尾,这条消息没有处理,请稍后重发。'
7
+ const HELD_NOTICE = '本群上一个任务结果不明,已暂停处理,等待人工确认;其他群不受影响。'
8
+ const UPGRADING_NOTICE = 'SintBot 正在升级,这条消息没有处理,请一分钟后重发。'
9
+ const reasonOf = error => String(error?.message || error || 'unknown').slice(0, 300)
7
10
 
8
11
  // Turns a group binding still holds: a running turn, a non-idle handoff or a
9
12
  // busy lifecycle. Scoped to one group's own journal.
@@ -23,12 +26,20 @@ function activeTurns(snapshot) {
23
26
  return turns.sort((a, b) => a.chatId.localeCompare(b.chatId))
24
27
  }
25
28
 
29
+ const runtimesOf = binding => binding?.engine?.rts instanceof Map ? [...binding.engine.rts.values()] : []
30
+
26
31
  // Process-local organization only. Each binding owns a distinct durable
27
32
  // authority; the transport and writer lease belong to this single process.
33
+ // A fault that only one group's journal can explain holds that group: its
34
+ // sealed binding stays as evidence, nothing is replayed or retried, and every
35
+ // other group keeps serving. Only a shared resource (writer lease, transport,
36
+ // model capacity) stops the process. `holds` lists groups whose journal could
37
+ // not be opened before the service was created.
28
38
  function createC2MultiGroupService({ routes, assertWriterLock,
29
39
  createTransport, createBinding, onFatal = () => {}, closeCapacity = () => {},
30
40
  quarantine = null, stopTimeoutMs = 2_000, drainTimeoutMs = 12_000,
31
- createRecoveryAuthority = null, log = () => {} } = {}) {
41
+ createRecoveryAuthority = null, holds = new Map(), sharedFault = () => null,
42
+ now = Date.now, log = () => {} } = {}) {
32
43
  if (!Array.isArray(routes) || !routes.length ||
33
44
  new Set(routes.map(route => route.chatId)).size !== routes.length ||
34
45
  routes.some(route => !route || typeof route.chatId !== 'string' ||
@@ -36,7 +47,9 @@ function createC2MultiGroupService({ routes, assertWriterLock,
36
47
  typeof assertWriterLock !== 'function' ||
37
48
  typeof createTransport !== 'function' || typeof createBinding !== 'function' ||
38
49
  typeof onFatal !== 'function' || typeof closeCapacity !== 'function' ||
39
- typeof log !== 'function' ||
50
+ typeof log !== 'function' || typeof sharedFault !== 'function' ||
51
+ typeof now !== 'function' || !(holds instanceof Map) ||
52
+ [...holds.keys()].some(chatId => !routes.some(route => route.chatId === chatId)) ||
40
53
  createRecoveryAuthority !== null && typeof createRecoveryAuthority !== 'function' ||
41
54
  !Number.isSafeInteger(stopTimeoutMs) || stopTimeoutMs < 1 ||
42
55
  !Number.isSafeInteger(drainTimeoutMs) || drainTimeoutMs < 1) {
@@ -44,6 +57,10 @@ function createC2MultiGroupService({ routes, assertWriterLock,
44
57
  }
45
58
  const configured = new Map(routes.map(route => [route.chatId, route]))
46
59
  const bindings = new Map()
60
+ // chatId -> { reason, source: 'startup' | 'runtime', since, binding }
61
+ const suspended = new Map()
62
+ // Set while a console upgrade switches the service: no message is admitted.
63
+ let maintenance = false
47
64
  let transport = null
48
65
  let started = false
49
66
  let starting = false
@@ -92,7 +109,8 @@ function createC2MultiGroupService({ routes, assertWriterLock,
92
109
  () => transport.stopPlatform(), stopTimeoutMs) === true
93
110
  const drained = (await Promise.all([...bindings.values()].map(binding =>
94
111
  bounded(() => binding.engine.drainDurableV2(drainTimeoutMs), drainTimeoutMs + 100))))
95
- .every(value => value === true)
112
+ .every(value => value === true) && [...suspended.values()].every(entry =>
113
+ runtimesOf(entry.binding).every(rt => !rt.v2Controller))
96
114
  return Object.freeze({ reason, code: code === 0 && startupStopped && platformStopped && drained ? 0 : 1,
97
115
  startupStopped, platformStopped, drained })
98
116
  })()
@@ -105,6 +123,22 @@ function createC2MultiGroupService({ routes, assertWriterLock,
105
123
  await onFatal(error, result)
106
124
  return result
107
125
  }
126
+ // Seal one group in place. Its binding and journal are kept unchanged as
127
+ // evidence; a later restart reopens it only through the normal recovery
128
+ // gate, or after its incident has been sealed by the operator.
129
+ function suspend(chatId, error, { source, binding = null }) {
130
+ if (suspended.has(chatId)) return suspended.get(chatId)
131
+ const entry = Object.freeze({ reason: reasonOf(error), source, since: now(), binding })
132
+ suspended.set(chatId, entry)
133
+ for (const sealed of new Set([binding, bindings.get(chatId)])) {
134
+ if (!sealed) continue
135
+ try { sealed.gate.stop() } catch {}
136
+ try { sealed.engine.shutdown() } catch {}
137
+ }
138
+ bindings.delete(chatId)
139
+ log(`group ${chatId}: held (${entry.reason}); other groups keep serving`)
140
+ return entry
141
+ }
108
142
  function allowed(chatId) {
109
143
  const route = configured.get(chatId)
110
144
  if (!route || route.paused === true) return false
@@ -155,6 +189,34 @@ function createC2MultiGroupService({ routes, assertWriterLock,
155
189
  work.then(() => recovering.delete(chatId), () => recovering.delete(chatId))
156
190
  return work
157
191
  }
192
+ // One group's route, recall or ingress failed. Close the failed turn when
193
+ // its journal proves every effect; otherwise hold the group alone. Returns
194
+ // 'recovered', 'held', 'fatal' (process stop begun) or 'stopping'.
195
+ async function groupFault(chatId, binding, error) {
196
+ const shared = () => !writerHeld() ? 'writer lock lost' : sharedFault()
197
+ if (shared()) { await fatal(error); return 'fatal' }
198
+ if (!started || stopPromise) return 'stopping'
199
+ if (suspended.has(chatId)) return 'held'
200
+ const current = bindings.get(chatId)
201
+ if (!binding || current !== binding && !failedBindings.has(binding)) {
202
+ // An error from a binding this service never published is not attributable.
203
+ await fatal(error); return 'fatal'
204
+ }
205
+ if (!createRecoveryAuthority) { await fatal(error); return 'fatal' }
206
+ try {
207
+ await recoverGroup(chatId, binding, error)
208
+ } catch (failure) {
209
+ log(`group ${chatId}: failed turn could not be closed: ${reasonOf(failure)}`)
210
+ if (shared()) { await fatal(failure); return 'fatal' }
211
+ if (!started || stopPromise) return 'stopping'
212
+ suspend(chatId, failure, { source: 'runtime', binding })
213
+ }
214
+ return suspended.has(chatId) ? 'held' : 'recovered'
215
+ }
216
+ function heldReply(msg, sourceAdapter) {
217
+ return answerC2InputRejection({ error: rejectC2Input('C2 group held',
218
+ { reason: 'group-held', notice: HELD_NOTICE }), msg, platform: sourceAdapter, log })
219
+ }
158
220
  // Open one more group on the live transport: a newly added route or one
159
221
  // paused since startup. It runs the same recovery gate as startup; only a
160
222
  // fully opened binding is published. Failure leaves the group unserved.
@@ -162,13 +224,16 @@ function createC2MultiGroupService({ routes, assertWriterLock,
162
224
  if (!route || typeof route.chatId !== 'string' || typeof route.file !== 'string') {
163
225
  return Promise.reject(new TypeError('C2 online route needs an explicit group and journal'))
164
226
  }
227
+ if (suspended.has(route.chatId)) {
228
+ return Promise.reject(new Error('This group is held after an unresolved fault; seal its incident and restart'))
229
+ }
165
230
  if (bindings.has(route.chatId)) return Promise.resolve(bindings.get(route.chatId))
166
231
  if (activating.has(route.chatId)) return activating.get(route.chatId)
167
232
  const opening = (async () => {
168
- if (!started || starting || stopPromise) throw new Error('C2 service is not serving')
233
+ if (!started || starting || stopPromise || maintenance) throw new Error('C2 service is not serving')
169
234
  held()
170
235
  const binding = await createBinding({ route, transport })
171
- if (!started || stopPromise || !writerHeld() ||
236
+ if (!started || stopPromise || !writerHeld() || suspended.has(route.chatId) ||
172
237
  !binding || !binding.gate || !binding.engine || binding.gate.blocked) {
173
238
  binding?.gate?.stop?.()
174
239
  binding?.engine?.shutdown?.()
@@ -184,40 +249,64 @@ function createC2MultiGroupService({ routes, assertWriterLock,
184
249
  return opening
185
250
  }
186
251
  function routeMessage(msg, sourceAdapter) {
252
+ const route = configured.get(msg?.chatId)
253
+ if (route && route.paused !== true && suspended.has(msg.chatId)) {
254
+ // A held group admits nothing; a replay of a sealed source stays silent.
255
+ if (quarantine?.rejects(msg)) return { quarantined: true }
256
+ if (started && !stopPromise) return heldReply(msg, sourceAdapter)
257
+ }
187
258
  // Foreign/paused group events are not faults and never get a receipt.
188
259
  if (!allowed(msg?.chatId)) return { ignored: true, reason: 'unconfigured-or-paused' }
189
260
  if (quarantine?.rejects(msg)) return { quarantined: true }
261
+ if (maintenance && started && !stopPromise) {
262
+ return answerC2InputRejection({ error: rejectC2Input('C2 service upgrading',
263
+ { reason: 'upgrading', notice: UPGRADING_NOTICE }), msg, platform: sourceAdapter, log })
264
+ }
190
265
  if (recovering.has(msg.chatId) && started && !stopPromise) {
191
266
  // The group's failed turn is being closed; nothing is admitted meanwhile.
192
267
  return answerC2InputRejection({ error: rejectC2Input('C2 group recovering',
193
268
  { reason: 'group-recovering', notice: RECOVERING_NOTICE }), msg, platform: sourceAdapter, log })
194
269
  }
270
+ let binding
195
271
  try {
196
272
  held()
197
- const binding = bindings.get(msg.chatId)
198
- if (!started || starting || stopPromise || !binding || binding.gate.blocked) {
273
+ binding = bindings.get(msg.chatId)
274
+ if (!started || starting || stopPromise || !binding) {
199
275
  throw new Error('C2 group ingress authority closed')
200
276
  }
277
+ if (binding.gate.blocked) {
278
+ // A turn still running under a closed authority delivers its own
279
+ // rejection; until then this message, which has no receipt, is
280
+ // refused. An idle closed group is closed as its own fault here.
281
+ if (runtimesOf(binding).some(rt => rt.v2NormalRouteActive)) {
282
+ return answerC2InputRejection({ error: rejectC2Input('C2 group closing',
283
+ { reason: 'group-recovering', notice: RECOVERING_NOTICE }), msg, platform: sourceAdapter, log })
284
+ }
285
+ return groupFault(msg.chatId, binding, new Error('C2 group ingress authority closed'))
286
+ .then(async outcome => {
287
+ if (outcome === 'held') return heldReply(msg, sourceAdapter)
288
+ if (outcome === 'recovered') {
289
+ return answerC2InputRejection({ error: rejectC2Input('C2 group recovered',
290
+ { reason: 'group-recovering', notice: RECOVERING_NOTICE }), msg, platform: sourceAdapter, log })
291
+ }
292
+ throw new Error('C2 group ingress authority closed')
293
+ })
294
+ }
201
295
  const running = binding.engine.runDurableV2Message(msg, sourceAdapter)
202
296
  return Promise.resolve(running).catch(async error => {
203
297
  // A refused command or premature /stop wrote nothing durable. Answer it
204
- // once and keep every group serving; any other error stays fatal.
298
+ // once and keep every group serving.
205
299
  if (isC2InputRejection(error) && started && !stopPromise &&
206
300
  !binding.gate.blocked && writerHeld()) {
207
301
  log(`group ${msg.chatId}: refused input before receipt (${error.reason})`)
208
302
  return answerC2InputRejection({ error, msg, platform: sourceAdapter, log })
209
303
  }
210
- // A closed model turn that failed (error or empty result) is closed for
211
- // this group alone. Other errors from that same superseded binding are
212
- // part of the same fault and wait for its recovery.
213
- if (createRecoveryAuthority && started && !stopPromise &&
214
- (failedBindings.has(binding) ||
215
- error?.code === 'C2_V2_UNRESOLVED_TURN' && bindings.get(msg.chatId) === binding)) {
216
- const recovered = await recoverGroup(msg.chatId, binding, error).then(() => true, failure => {
217
- log(`group ${msg.chatId}: failed turn could not be closed: ${failure?.message || String(failure)}`)
218
- return false
219
- })
220
- if (recovered) return { recovered: true, reason: 'group-fault-closed' }
304
+ // Any other route error belongs to this group alone unless a shared
305
+ // resource failed. Errors from a superseded binding join its fault.
306
+ if (started && !stopPromise) {
307
+ const outcome = await groupFault(msg.chatId, binding, error)
308
+ if (outcome === 'recovered') return { recovered: true, reason: 'group-fault-closed' }
309
+ if (outcome === 'held') return { held: true, reason: 'group-held' }
221
310
  }
222
311
  await fatal(error)
223
312
  throw error
@@ -236,15 +325,23 @@ function createC2MultiGroupService({ routes, assertWriterLock,
236
325
  }
237
326
  }
238
327
  function recall(event) {
328
+ if (suspended.has(event?.chatId)) return { ignored: true, reason: 'group-held' }
329
+ if (maintenance) return { ignored: true, reason: 'upgrading' }
239
330
  if (!allowed(event?.chatId)) return { ignored: true }
331
+ let binding
240
332
  try {
241
333
  held()
242
- const binding = bindings.get(event.chatId)
334
+ binding = bindings.get(event.chatId)
243
335
  if (!started || stopPromise || !binding) return { ignored: true, reason: 'ingress-closed' }
244
336
  const handled = binding.engine.handleRecall?.(event) === true
245
337
  if (binding.runtime?.blocked) throw new Error('Recall closed its durable authority')
246
338
  return handled ? { handled: true } : { ignored: true, reason: 'unsupported-or-inactive-recall' }
247
- } catch (error) { return fatal(error) }
339
+ } catch (error) {
340
+ if (!binding || !writerHeld() || !started || stopPromise) return fatal(error)
341
+ return groupFault(event.chatId, binding, error).then(outcome =>
342
+ outcome === 'recovered' || outcome === 'held'
343
+ ? { ignored: true, reason: `group-${outcome}` } : fatal(error))
344
+ }
248
345
  }
249
346
  async function start({ preflightOnly = false } = {}) {
250
347
  if (started || starting || stopPromise) throw new Error('C2 service already started or stopped')
@@ -260,9 +357,19 @@ function createC2MultiGroupService({ routes, assertWriterLock,
260
357
  typeof transport.stopPlatform !== 'function') {
261
358
  throw new TypeError('C2 transport lacks a single start/stop boundary')
262
359
  }
360
+ for (const [chatId, reason] of holds) suspend(chatId, reason, { source: 'startup' })
263
361
  for (const route of routes) {
264
- if (route.paused) continue // Its settled journal is audited before start.
265
- const binding = await createBinding({ route, transport })
362
+ if (route.paused || suspended.has(route.chatId)) continue // Paused journals are audited before start.
363
+ let binding
364
+ try {
365
+ binding = await createBinding({ route, transport })
366
+ } catch (error) {
367
+ held()
368
+ if (stopPromise) throw new Error('C2 service stopped during group recovery')
369
+ // This group's journal could not be recovered; it alone stays closed.
370
+ suspend(route.chatId, error, { source: 'startup' })
371
+ continue
372
+ }
266
373
  held()
267
374
  if (stopPromise) {
268
375
  binding?.gate?.stop?.()
@@ -273,11 +380,19 @@ function createC2MultiGroupService({ routes, assertWriterLock,
273
380
  if (!binding || !binding.gate || !binding.engine || binding.gate.blocked) {
274
381
  binding?.gate?.stop?.()
275
382
  binding?.engine?.shutdown?.()
276
- throw new Error('C2 group binding did not open its durable authority')
383
+ suspend(route.chatId, 'C2 group binding did not open its durable authority', { source: 'startup' })
384
+ continue
277
385
  }
278
386
  bindings.set(route.chatId, binding)
279
387
  }
280
388
  held()
389
+ const heldList = [...suspended].map(([chatId, entry]) => `${chatId}: ${entry.reason}`)
390
+ if (preflightOnly && heldList.length) {
391
+ throw new Error(`Startup preflight would hold ${heldList.length} group(s): ${heldList.join('; ')}`)
392
+ }
393
+ if (heldList.length && !bindings.size) {
394
+ throw new Error(`Every active group is held: ${heldList.join('; ')}`)
395
+ }
281
396
  if (preflightOnly) {
282
397
  finishStartup()
283
398
  await stop('preflight', 0)
@@ -291,14 +406,30 @@ function createC2MultiGroupService({ routes, assertWriterLock,
291
406
  onCardAction: unsupported('card action'), onBoardUpdated: unsupported('board update') })
292
407
  held()
293
408
  if (stopPromise) throw new Error('C2 service stopped during connection')
294
- return { groups: bindings.size, connected: true }
409
+ return { groups: bindings.size, connected: true, ...(suspended.size ? { held: heldGroups() } : {}) }
295
410
  } catch (error) {
296
411
  finishStartup()
297
412
  await stop('startup-failed', 1)
298
413
  throw error
299
414
  } finally { starting = false; finishStartup() }
300
415
  }
301
- return { start, stop, routeMessage, addRoute, get bindings() { return new Map(bindings) } }
416
+ function heldGroups() {
417
+ return [...suspended].map(([chatId, entry]) => Object.freeze({ chatId,
418
+ reason: entry.reason, source: entry.source, since: entry.since }))
419
+ }
420
+ function beginMaintenance() {
421
+ if (!maintenance) log('upgrade: admission closed for the service switch')
422
+ maintenance = true
423
+ }
424
+ function endMaintenance() {
425
+ if (maintenance) log('upgrade: admission reopened')
426
+ maintenance = false
427
+ }
428
+ return { start, stop, routeMessage, addRoute, heldGroups, beginMaintenance, endMaintenance,
429
+ get inMaintenance() { return maintenance },
430
+ get bindings() { return new Map(bindings) },
431
+ get held() { return new Map([...suspended].map(([chatId, entry]) =>
432
+ [chatId, { reason: entry.reason, source: entry.source, since: entry.since }])) } }
302
433
  }
303
434
 
304
435
  module.exports = { createC2MultiGroupService }