sintbot 1.4.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 +19 -0
- package/CHANGELOG.json +19 -0
- package/bin/sintbot-v2.js +61 -14
- package/docs/AI-ASSISTED-INSTALL.md +2 -2
- package/docs/install.md +11 -5
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +2 -2
- package/docs/zh-CN/install.md +11 -5
- package/install.sh +1 -1
- package/package.json +2 -1
- package/src/agent/claudecode.js +7 -2
- package/src/agent/codexapp.js +7 -2
- package/src/agent/codexcode.js +8 -3
- package/src/c2configcontrol.js +11 -2
- package/src/c2console.js +9 -4
- package/src/c2incident.js +125 -0
- package/src/c2install.js +1 -1
- package/src/c2multigroupservice.js +139 -25
- package/src/core/c2sourcequarantine.js +22 -3
- package/src/core/v2engine.js +23 -1
- package/src/ui/console.html +5 -3
package/CHANGELOG.en.json
CHANGED
|
@@ -1,4 +1,23 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.4.1",
|
|
4
|
+
"date": "2026-11-09",
|
|
5
|
+
"name": "A failing group stops only itself",
|
|
6
|
+
"items": [
|
|
7
|
+
{
|
|
8
|
+
"t": "One group's fault leaves the others working",
|
|
9
|
+
"d": "When a group's last task has an outcome that cannot be confirmed (for example a reply whose delivery is unknown, or a runner that exited without a result), only that group is paused; nothing is replayed or retried and the other groups keep working. A paused group answers new messages with a short notice."
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"t": "One group no longer blocks startup",
|
|
13
|
+
"d": "If a group's records cannot be recovered, the other groups still start and the console marks that group Needs attention. The startup preflight fails and lists such groups."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"t": "Incident seal command",
|
|
17
|
+
"d": "Restart first; for a group that is still paused, stop the service and run sintbot incident seal --group <group ID>. The old records are kept unchanged, the group reopens with new records, and messages left unfinished before the seal are not processed again."
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
},
|
|
2
21
|
{
|
|
3
22
|
"version": "1.4.0",
|
|
4
23
|
"date": "2026-11-08",
|
package/CHANGELOG.json
CHANGED
|
@@ -1,4 +1,23 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.4.1",
|
|
4
|
+
"date": "2026-11-09",
|
|
5
|
+
"name": "一个群出问题,只停这个群",
|
|
6
|
+
"items": [
|
|
7
|
+
{
|
|
8
|
+
"t": "一个群出问题,其他群照常",
|
|
9
|
+
"d": "某个群上一个任务的结果无法确认时(例如回复是否送达不明,或执行器退出时没有结果),只暂停这个群,不重放、不重试,其他群继续处理。暂停的群收到新消息会回一句说明。"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"t": "启动时不再被一个群拖住",
|
|
13
|
+
"d": "某个群的记录恢复不了时,其他群照常启动,控制台把这个群标为「需要处理」。启动预检遇到这样的群会直接失败并列出原因。"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"t": "新增封存命令",
|
|
17
|
+
"d": "先重启服务;仍然暂停的群,在服务停止后运行 sintbot incident seal --group 群 ID。原记录原样封存,这个群换一份新记录重新开放,封存前未完成的消息不会再处理。"
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
},
|
|
2
21
|
{
|
|
3
22
|
"version": "1.4.0",
|
|
4
23
|
"date": "2026-11-08",
|
package/bin/sintbot-v2.js
CHANGED
|
@@ -122,11 +122,18 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
|
|
|
122
122
|
for (const name of ['pending-turns.json', 'engine-receipts.json']) {
|
|
123
123
|
if (fs.existsSync(path.join(stateDir, name))) throw new Error('Legacy pending authority needs explicit migration')
|
|
124
124
|
}
|
|
125
|
+
// A journal that cannot be opened or audited holds only its own group.
|
|
125
126
|
const journals = new Map()
|
|
127
|
+
const holds = new Map()
|
|
126
128
|
for (const route of routes) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
129
|
+
try {
|
|
130
|
+
const journal = C2DurableJournal.open(route.file, validators)
|
|
131
|
+
assertJournalScope(journal, route.chatId, { requireSettled: preflightOnly || route.paused })
|
|
132
|
+
journals.set(route.chatId, journal)
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (!assertWriterLock()) throw error
|
|
135
|
+
holds.set(route.chatId, error?.message || String(error))
|
|
136
|
+
}
|
|
130
137
|
}
|
|
131
138
|
// All group Engines must share this exact object: separate Store caches
|
|
132
139
|
// would overwrite sibling sessions when they save the same state file.
|
|
@@ -136,6 +143,13 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
|
|
|
136
143
|
'codex-exec': require('../src/agent/codexcode').isClosedRunnerResult,
|
|
137
144
|
'codex-app': require('../src/agent/codexapp').isClosedRunnerResult,
|
|
138
145
|
}
|
|
146
|
+
// A runner that failed from its own exit event has closed; this frees its
|
|
147
|
+
// model permit but never proves the turn's outcome.
|
|
148
|
+
const closeErrorChecks = {
|
|
149
|
+
claude: require('../src/agent/claudecode').isClosedRunnerError,
|
|
150
|
+
'codex-exec': require('../src/agent/codexcode').isClosedRunnerError,
|
|
151
|
+
'codex-app': require('../src/agent/codexapp').isClosedRunnerError,
|
|
152
|
+
}
|
|
139
153
|
const limits = { maxEventCount: 100_000, maxJournalBytes: 64 * 1024 * 1024,
|
|
140
154
|
maxCompletionEvents: 1_000, maxCompletionBytes: 8 * 1024 * 1024 }
|
|
141
155
|
let finishing = false
|
|
@@ -148,7 +162,9 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
|
|
|
148
162
|
() => process.exit(result.code))
|
|
149
163
|
}
|
|
150
164
|
service = createC2MultiGroupService({ routes, assertWriterLock, quarantine: combinedQuarantine,
|
|
151
|
-
closeCapacity: () => capacity.close(),
|
|
165
|
+
holds, closeCapacity: () => capacity.close(),
|
|
166
|
+
// Faults in a shared resource stop every group; others hold one group.
|
|
167
|
+
sharedFault: () => capacity.snapshot().closed ? 'model concurrency closed' : null,
|
|
152
168
|
log: message => process.stderr.write(`C2 multi-group · ${message}\n`),
|
|
153
169
|
onFatal: (error, result) => {
|
|
154
170
|
process.stderr.write(`C2 multi-group failure: ${error?.message || String(error)}\n`)
|
|
@@ -199,10 +215,15 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
|
|
|
199
215
|
verifyFile: path.join(stateDir, 'verify.json'), assertWriterLock,
|
|
200
216
|
resolveTurn: identity => resolveC2LocalEffectTurn(engine, identity) })
|
|
201
217
|
const closedResults = new WeakSet()
|
|
218
|
+
const closedErrors = new WeakSet()
|
|
202
219
|
const startTurn = async input => {
|
|
203
220
|
const backend = routeBackend(input.provider?.runner, input.codexApp,
|
|
204
221
|
input.codexSteer, input.codexProjectDocFallback)
|
|
205
|
-
|
|
222
|
+
let result
|
|
223
|
+
try { result = await runner.runTurn(input) } catch (error) {
|
|
224
|
+
if (closeErrorChecks[backend]?.(error) === true) closedErrors.add(error)
|
|
225
|
+
throw error
|
|
226
|
+
}
|
|
206
227
|
if (closeChecks[backend]?.(result) === true) closedResults.add(result)
|
|
207
228
|
return result
|
|
208
229
|
}
|
|
@@ -210,14 +231,15 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
|
|
|
210
231
|
store, log: message => process.stderr.write(`C2 v2 · ${message}\n`),
|
|
211
232
|
durableV2Runtime: runtime, durableV2Payloads: handles,
|
|
212
233
|
durableV2StartTurn: startTurn, durableV2ApplyLocalEffect: applyLocalEffect,
|
|
213
|
-
durableV2VerifyRunnerClosed: ({ result }) =>
|
|
214
|
-
typeof result === 'object' && closedResults.has(result)
|
|
234
|
+
durableV2VerifyRunnerClosed: ({ result, error }) => result
|
|
235
|
+
? typeof result === 'object' && closedResults.has(result)
|
|
236
|
+
: !!error && typeof error === 'object' && closedErrors.has(error),
|
|
215
237
|
durableV2AssertWriterLock: assertWriterLock, durableV2Journal: journal,
|
|
216
238
|
durableV2AcquireCapacity: options => capacity.acquire(options),
|
|
217
|
-
// A
|
|
218
|
-
//
|
|
239
|
+
// A route fault holds or recovers its group alone. Shared grants
|
|
240
|
+
// are sealed only when the process writer lease itself is gone.
|
|
219
241
|
durableV2SealCapacity: error => {
|
|
220
|
-
if (
|
|
242
|
+
if (assertWriterLock() !== true) capacity.close(error)
|
|
221
243
|
},
|
|
222
244
|
durableV2SwitchProvider: (chatId, name) => {
|
|
223
245
|
if (chatId !== route.chatId) throw new Error('Model switch targets another group')
|
|
@@ -231,7 +253,8 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
|
|
|
231
253
|
} })
|
|
232
254
|
// One configuration writer for the console and the chat /model command.
|
|
233
255
|
configControl = require('../src/c2configcontrol').createC2ConfigControl({
|
|
234
|
-
installation, getBindings: () => service.bindings,
|
|
256
|
+
installation, getBindings: () => service.bindings, getHeld: () => service.held,
|
|
257
|
+
assertWriterLock, store,
|
|
235
258
|
activateRoute: route => service.addRoute(route),
|
|
236
259
|
log: message => process.stderr.write(`C2 multi-group · ${message}\n`) })
|
|
237
260
|
const stop = reason => { void service.stop(reason, 0).then(finish) }
|
|
@@ -243,7 +266,7 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
|
|
|
243
266
|
ui = require('../src/c2console').createC2Console({
|
|
244
267
|
configPath: cfg._path, stateDir, host: uiHost,
|
|
245
268
|
getConfig: () => cfg, getBindings: () => service.bindings,
|
|
246
|
-
getCapacity: () => capacity.snapshot(),
|
|
269
|
+
getHeld: () => service.held, getCapacity: () => capacity.snapshot(),
|
|
247
270
|
getConnHealth: () => liveTransport?.platform.connHealth?.() || {},
|
|
248
271
|
assertWriterLock, ...configControl,
|
|
249
272
|
log: message => process.stderr.write(`Console · ${message}\n`) })
|
|
@@ -261,6 +284,9 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
|
|
|
261
284
|
process.on('exit', lock.release)
|
|
262
285
|
running = true
|
|
263
286
|
process.stderr.write(`SintBot Feishu ingress connected for ${started.groups} groups\n`)
|
|
287
|
+
for (const item of started.held || []) {
|
|
288
|
+
process.stderr.write(`SintBot group ${item.chatId} is held: ${item.reason}\n`)
|
|
289
|
+
}
|
|
264
290
|
} catch (error) {
|
|
265
291
|
ui?.close()
|
|
266
292
|
const stopped = await service?.stop('startup-failed', 1)
|
|
@@ -303,6 +329,7 @@ const HELP = `SintBot macOS Feishu multi-group candidate
|
|
|
303
329
|
Usage:
|
|
304
330
|
sintbot init [--config <absolute path>] [--data-dir <absolute path>]
|
|
305
331
|
sintbot start [--config <absolute path>] [--data-dir <absolute path>] [--preflight] [--ui] [--ui-host <address>] [--ui-port <port>] [--no-open]
|
|
332
|
+
sintbot incident seal --group <oc_…> [--config <absolute path>] [--data-dir <absolute path>] [--reason <text>]
|
|
306
333
|
sintbot version
|
|
307
334
|
sintbot help
|
|
308
335
|
|
|
@@ -311,7 +338,10 @@ Initialization is for a new installation only; existing journals are never reset
|
|
|
311
338
|
Console shows live groups, binds groups and switches models or projects of idle groups;
|
|
312
339
|
in a group, /model lists or switches models. Unsupported actions stay disabled.
|
|
313
340
|
Canvas, scheduled tasks, terminal chat and other platforms are unavailable.
|
|
314
|
-
|
|
341
|
+
A group whose last result cannot be proven is held alone; the other groups keep serving.
|
|
342
|
+
Restart first: a journal that can recover reopens on its own. If the group is still held,
|
|
343
|
+
stop the service, run incident seal for that group, then start again.
|
|
344
|
+
Sealing keeps the old journal unchanged; unknown effects are never replayed.
|
|
315
345
|
`
|
|
316
346
|
|
|
317
347
|
async function main(argv = process.argv.slice(2)) {
|
|
@@ -326,10 +356,27 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
326
356
|
process.stdout.write(HELP)
|
|
327
357
|
return
|
|
328
358
|
}
|
|
329
|
-
if (!['init', 'start'].includes(sub)) {
|
|
359
|
+
if (!['init', 'start', 'incident'].includes(sub)) {
|
|
330
360
|
throw new Error(`${sub} is unavailable in this release; use sintbot help`)
|
|
331
361
|
}
|
|
332
362
|
if (process.platform !== 'darwin') throw new Error('this release supports macOS only')
|
|
363
|
+
if (sub === 'incident') {
|
|
364
|
+
const [action, ...args] = rest
|
|
365
|
+
if (rest.some(arg => arg === '--help' || arg === '-h')) { process.stdout.write(HELP); return }
|
|
366
|
+
if (action !== 'seal') throw new Error('Usage: sintbot incident seal --group <oc_…> [--config <path>] [--data-dir <path>]')
|
|
367
|
+
const { values } = parseArgs({ args, allowPositionals: false, options: {
|
|
368
|
+
config: { type: 'string' }, 'data-dir': { type: 'string' },
|
|
369
|
+
group: { type: 'string' }, reason: { type: 'string' } } })
|
|
370
|
+
const installation = require('../src/c2install').loadInstallation(values.config,
|
|
371
|
+
{ dataDir: values['data-dir'], allowAddedGroups: true })
|
|
372
|
+
const result = require('../src/c2incident').sealC2GroupIncident({ installation,
|
|
373
|
+
chatId: values.group, ...(values.reason !== undefined ? { reason: values.reason } : {}),
|
|
374
|
+
version: require('../package.json').version })
|
|
375
|
+
process.stdout.write(`Sealed the group's previous journal unchanged in ${result.incident}.\n` +
|
|
376
|
+
`${result.refusedSources} unsettled message(s) will not be processed again. ` +
|
|
377
|
+
'Start the service to reopen the group with a new journal.\n')
|
|
378
|
+
return
|
|
379
|
+
}
|
|
333
380
|
if (sub === 'init') {
|
|
334
381
|
const { values } = parseArgs({ args: rest, allowPositionals: false,
|
|
335
382
|
options: { config: { type: 'string' }, 'data-dir': { type: 'string' } } })
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Install SintBot 1.4.
|
|
1
|
+
# Install SintBot 1.4.1 with an AI assistant
|
|
2
2
|
|
|
3
3
|
English · [简体中文](./zh-CN/AI-ASSISTED-INSTALL.md)
|
|
4
4
|
|
|
@@ -15,5 +15,5 @@ Use the terminal on the Mac that will run SintBot. A cloud shell cannot install
|
|
|
15
15
|
## Copyable request
|
|
16
16
|
|
|
17
17
|
```text
|
|
18
|
-
Install SintBot 1.4.
|
|
18
|
+
Install SintBot 1.4.1 on this Mac using the one-line installation guide. Preserve existing services, configuration, state and Codex or Claude Code account data. Do not expose credentials. Verify the actual installed executable and binary core, configure the Feishu groups with their runners, and initialize only clean new state. Run the foreground preflight and an authorized read-only group test, check the result, then verify clean stop/restart. Stop temporary processes when finished, and distinguish package installation from successful group acceptance.
|
|
19
19
|
```
|
package/docs/install.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Install SintBot 1.4.
|
|
1
|
+
# Install SintBot 1.4.1
|
|
2
2
|
|
|
3
3
|
English · [简体中文](./zh-CN/install.md)
|
|
4
4
|
|
|
@@ -10,7 +10,7 @@ You need a Mac, Node.js 22 or newer, and projects stored on that Mac. Install an
|
|
|
10
10
|
curl -fsSL https://sintbot.com/install.sh | bash
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
The installer checks the environment, installs `sintbot@1.4.
|
|
13
|
+
The installer checks the environment, installs `sintbot@1.4.1` into `~/.local/share/sintbot`, and verifies the entry, built-in core and Feishu module. Continue when you see `Installed and verified SintBot 1.4.1`. No sudo is needed.
|
|
14
14
|
|
|
15
15
|
To preview, append `-s -- --dry-run`. To choose another location, set `SINTBOT_INSTALL_DIR` and adjust the executable paths below.
|
|
16
16
|
|
|
@@ -98,11 +98,11 @@ Any text message is a task. Replies arrive in a card that appears once the model
|
|
|
98
98
|
| `/hud` | Quota of the current model and usage of this conversation |
|
|
99
99
|
| `/issue text` | Log feedback; review and close it in the console overview |
|
|
100
100
|
|
|
101
|
-
Messages sent while a task runs are added to that task in Codex groups (`codexSteer: true`) and queued in Claude Code groups. If a turn returns a model error or no result, only that group is asked to resend; there is no automatic retry and other groups
|
|
101
|
+
Messages sent while a task runs are added to that task in Codex groups (`codexSteer: true`) and queued in Claude Code groups. If a turn returns a model error or no result, only that group is asked to resend; there is no automatic retry. If a group's last result cannot be confirmed, such as a reply whose delivery is unknown, only that group is paused and answers new messages with a short notice; other groups keep working. Nothing is replayed or retried.
|
|
102
102
|
|
|
103
103
|
## 6. Console
|
|
104
104
|
|
|
105
|
-
With `--ui`, open `http://127.0.0.1:4600` on this Mac. The console shows each group, stops tasks, binds new groups, and switches the model or project folder of an idle group. For LAN access, set a console password in its settings first, then enable LAN access.
|
|
105
|
+
With `--ui`, open `http://127.0.0.1:4600` on this Mac. The console shows each group (a paused group shows Needs attention), stops tasks, binds new groups, and switches the model or project folder of an idle group. For LAN access, set a console password in its settings first, then enable LAN access.
|
|
106
106
|
|
|
107
107
|
You can also add a group without the console: add it to the configuration, stop the service and run `start` again; the new group is registered automatically. Bound groups cannot be removed or renamed by editing the configuration.
|
|
108
108
|
|
|
@@ -114,6 +114,12 @@ Press Ctrl-C and wait for exit. With the Feishu variables set, run:
|
|
|
114
114
|
"$HOME/.local/share/sintbot/bin/sintbot" start --config "$HOME/.sbot/config.json" --ui
|
|
115
115
|
```
|
|
116
116
|
|
|
117
|
-
If
|
|
117
|
+
If a group stays paused, first restart: a group whose records can recover reopens on its own and is told once to resend. If it is still paused, check what actually happened in that group, then stop the service and seal the group's incident:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
"$HOME/.local/share/sintbot/bin/sintbot" incident seal --config "$HOME/.sbot/config.json" --group oc_PAUSED_GROUP_ID
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Sealing keeps the old journal unchanged under `incidents/` in the data directory and gives the group a new journal; messages left unfinished before the seal are never processed again. Start the service again to reopen the group. A preflight fails while any group would stay paused. Never delete journals or resend to get around a paused group. Run only one connected process per Feishu app. This release does not register a background service.
|
|
118
124
|
|
|
119
125
|
[Illustrated website guide](https://sintbot.com/guide/install/) · [AI-assisted installation](./AI-ASSISTED-INSTALL.md)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# 用 AI 助手安装 SintBot 1.4.
|
|
1
|
+
# 用 AI 助手安装 SintBot 1.4.1
|
|
2
2
|
|
|
3
3
|
[English](../AI-ASSISTED-INSTALL.md) · 简体中文
|
|
4
4
|
|
|
@@ -15,5 +15,5 @@
|
|
|
15
15
|
## 可复制的请求
|
|
16
16
|
|
|
17
17
|
```text
|
|
18
|
-
按一行安装指南,在这台 Mac 安装 SintBot 1.4.
|
|
18
|
+
按一行安装指南,在这台 Mac 安装 SintBot 1.4.1。保留已有服务、配置、状态和 Codex 或 Claude Code 账号数据,不输出凭据。验证实际入口与二进制核心,配置飞书群及各群的执行器,只对全新状态初始化。运行前台预检和已授权的只读群测试,对照结果,再验证正常停止与重启。完成后停止临时进程,并区分程序包安装与群验收结果。
|
|
19
19
|
```
|
package/docs/zh-CN/install.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# 安装 SintBot 1.4.
|
|
1
|
+
# 安装 SintBot 1.4.1
|
|
2
2
|
|
|
3
3
|
[English](../install.md) · 简体中文
|
|
4
4
|
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
curl -fsSL https://sintbot.com/install.sh | bash
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
安装器会自动检查环境,把 `sintbot@1.4.
|
|
13
|
+
安装器会自动检查环境,把 `sintbot@1.4.1` 装到 `~/.local/share/sintbot`,并验证程序入口、内置核心和飞书模块。看到 `Installed and verified SintBot 1.4.1` 就可以继续。不需要 sudo。
|
|
14
14
|
|
|
15
15
|
可选预览:在命令末尾加 `-s -- --dry-run`。要自定义安装位置,设置 `SINTBOT_INSTALL_DIR` 环境变量,并同步修改后面的可执行文件路径。
|
|
16
16
|
|
|
@@ -98,11 +98,11 @@ SINTBOT="$HOME/.local/share/sintbot/bin/sintbot"
|
|
|
98
98
|
| `/hud` | 当前模型的额度与本段对话的用量 |
|
|
99
99
|
| `/issue 一句话` | 记下问题反馈,在控制台概览里查看和标记处理 |
|
|
100
100
|
|
|
101
|
-
任务进行中再发的消息:Codex 群(`codexSteer: true`)会补充进当前任务,Claude Code
|
|
101
|
+
任务进行中再发的消息:Codex 群(`codexSteer: true`)会补充进当前任务,Claude Code 群会排队,等这一轮结束再处理。某一轮模型报错或没有结果时,只在本群提示重新发送,不会自动重试。某个群上一轮的结果无法确认时(例如回复是否送达不明),只暂停这个群,它收到新消息会回一句说明,其他群照常;不重放、不重试。
|
|
102
102
|
|
|
103
103
|
## 6. 控制台
|
|
104
104
|
|
|
105
|
-
带 `--ui` 启动后,在本机浏览器打开 `http://127.0.0.1:4600
|
|
105
|
+
带 `--ui` 启动后,在本机浏览器打开 `http://127.0.0.1:4600`。控制台可以查看各群状态(暂停的群显示「需要处理」)、停止任务、绑定新群、给空闲的群切换模型或项目目录。要从局域网访问,先在控制台的设置里设访问密码,再开启局域网访问。
|
|
106
106
|
|
|
107
107
|
不用控制台也能加群:在配置里加上新群,停止后重新 `start` 即可,新群会自动登记。已绑定的群不能通过改配置删除或改名。
|
|
108
108
|
|
|
@@ -114,6 +114,12 @@ SINTBOT="$HOME/.local/share/sintbot/bin/sintbot"
|
|
|
114
114
|
"$HOME/.local/share/sintbot/bin/sintbot" start --config "$HOME/.sbot/config.json" --ui
|
|
115
115
|
```
|
|
116
116
|
|
|
117
|
-
|
|
117
|
+
某个群一直暂停时,先重启:记录能恢复的群会自己重新开放,并提示一次重新发送。重启后仍暂停,先核对这个群实际发生了什么,再停止服务,封存这个群的事故记录:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
"$HOME/.local/share/sintbot/bin/sintbot" incident seal --config "$HOME/.sbot/config.json" --group oc_暂停的群ID
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
封存会把原日志原样留在数据目录的 `incidents/` 下,并给这个群换一份新日志;封存前没处理完的消息不会再处理。再次启动服务,这个群就重新开放。有群会保持暂停时,预检会失败。不要通过删除日志或重复发送来绕过暂停。一个飞书应用同一时间只运行一个连接进程。本版不自动注册后台服务。
|
|
118
124
|
|
|
119
125
|
[带图示的官网指南](https://sintbot.com/zh/guide/install/) · [AI 辅助安装](./AI-ASSISTED-INSTALL.md)
|
package/install.sh
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# The function is parsed in full before installation starts, including when piped.
|
|
3
3
|
sintbot_install() (
|
|
4
4
|
set -eu
|
|
5
|
-
version=1.4.
|
|
5
|
+
version=1.4.1
|
|
6
6
|
prefix="${SINTBOT_INSTALL_DIR:-$HOME/.local/share/sintbot}"
|
|
7
7
|
fail() { printf 'SintBot: %s\n' "$*" >&2; exit 1; }
|
|
8
8
|
case "${1:-}" in
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sintbot",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Drive your Mac projects from Feishu groups with Codex or Claude Code, using a built-in agent core.",
|
|
6
6
|
"bin": {
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
"src/agent/winexec.js",
|
|
59
59
|
"src/c2configcontrol.js",
|
|
60
60
|
"src/c2console.js",
|
|
61
|
+
"src/c2incident.js",
|
|
61
62
|
"src/c2install.js",
|
|
62
63
|
"src/c2migration.js",
|
|
63
64
|
"src/c2multigroupcapacity.js",
|
package/src/agent/claudecode.js
CHANGED
|
@@ -29,6 +29,11 @@ const { IS_WIN, resolveSpawn } = require('./winexec')
|
|
|
29
29
|
const { tagError } = require('./errtag') // 出错原话 → quota / sessionMissing / transient 之一(优先级只在那一处定),与 codex runner 共用
|
|
30
30
|
const closedRunnerResults = new WeakSet()
|
|
31
31
|
const isClosedRunnerResult = result => !!result && typeof result === 'object' && closedRunnerResults.has(result)
|
|
32
|
+
// A rejection raised from the child's own 'close' event: the process has
|
|
33
|
+
// exited even though the turn has no result. It never proves the turn's outcome.
|
|
34
|
+
const closedRunnerErrors = new WeakSet()
|
|
35
|
+
const isClosedRunnerError = error => !!error && typeof error === 'object' && closedRunnerErrors.has(error)
|
|
36
|
+
const closedError = error => { closedRunnerErrors.add(error); return error }
|
|
32
37
|
const PRE_INIT_HOOK_EVENTS = new Set(['hook_started', 'hook_response'])
|
|
33
38
|
const DURABLE_EVENT_TYPES = new Set(['system', 'assistant', 'user', 'result', 'tool_result'])
|
|
34
39
|
// Tool output is completion evidence only, never answer text.
|
|
@@ -451,7 +456,7 @@ function runTurn({
|
|
|
451
456
|
|
|
452
457
|
const completeClose = (code) => finish(() => {
|
|
453
458
|
if (code !== 0 && !lastResult && !aborted) {
|
|
454
|
-
return reject(new Error(`claude exited ${code}: ${stderr.trim().slice(0, 800) || '(no stderr)'}`))
|
|
459
|
+
return reject(closedError(new Error(`claude exited ${code}: ${stderr.trim().slice(0, 800) || '(no stderr)'}`)))
|
|
455
460
|
}
|
|
456
461
|
resolveRun({ sessionId: capturedSessionId, result: lastResult, code, aborted })
|
|
457
462
|
})
|
|
@@ -524,4 +529,4 @@ function probeVersion(bin = 'claude') {
|
|
|
524
529
|
})
|
|
525
530
|
}
|
|
526
531
|
|
|
527
|
-
module.exports = { runTurn, isClosedRunnerResult, buildEnv, normalizeEvent, probeVersion, supportsFlag, BASE_SKILL_ALLOWS }
|
|
532
|
+
module.exports = { runTurn, isClosedRunnerResult, isClosedRunnerError, buildEnv, normalizeEvent, probeVersion, supportsFlag, BASE_SKILL_ALLOWS }
|
package/src/agent/codexapp.js
CHANGED
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
const { spawn } = require('node:child_process')
|
|
5
5
|
const closedRunnerResults = new WeakSet()
|
|
6
6
|
const isClosedRunnerResult = result => !!result && typeof result === 'object' && closedRunnerResults.has(result)
|
|
7
|
+
// A rejection raised from the child's own 'close' event: the process has
|
|
8
|
+
// exited even though the turn has no result. It never proves the turn's outcome.
|
|
9
|
+
const closedRunnerErrors = new WeakSet()
|
|
10
|
+
const isClosedRunnerError = error => !!error && typeof error === 'object' && closedRunnerErrors.has(error)
|
|
11
|
+
const closedError = error => { closedRunnerErrors.add(error); return error }
|
|
7
12
|
const { IS_WIN, resolveSpawn } = require('./winexec')
|
|
8
13
|
const { normalizeEvent, isEffortRejection } = require('./codexcode')
|
|
9
14
|
const { coreFailedResult, isCoreFailure } = require('./errtag')
|
|
@@ -439,7 +444,7 @@ function runTurn(opts = {}) {
|
|
|
439
444
|
})
|
|
440
445
|
const completeClose = (code) => settle(() => {
|
|
441
446
|
if (onDurableEvent && !completed && !aborted) {
|
|
442
|
-
reject(new Error(`codex app-server exited ${code} before a durable result`))
|
|
447
|
+
reject(closedError(new Error(`codex app-server exited ${code} before a durable result`)))
|
|
443
448
|
return
|
|
444
449
|
}
|
|
445
450
|
if (!completed && !aborted) {
|
|
@@ -467,4 +472,4 @@ function runTurn(opts = {}) {
|
|
|
467
472
|
})
|
|
468
473
|
}
|
|
469
474
|
|
|
470
|
-
module.exports = { runTurn, isClosedRunnerResult, usageFromTotal, itemEvent, failedTurnEvent }
|
|
475
|
+
module.exports = { runTurn, isClosedRunnerResult, isClosedRunnerError, usageFromTotal, itemEvent, failedTurnEvent }
|
package/src/agent/codexcode.js
CHANGED
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
const { spawn } = require('node:child_process')
|
|
14
14
|
const closedRunnerResults = new WeakSet()
|
|
15
15
|
const isClosedRunnerResult = result => !!result && typeof result === 'object' && closedRunnerResults.has(result)
|
|
16
|
+
// A rejection raised from the child's own 'close' event: the process has
|
|
17
|
+
// exited even though the turn has no result. It never proves the turn's outcome.
|
|
18
|
+
const closedRunnerErrors = new WeakSet()
|
|
19
|
+
const isClosedRunnerError = error => !!error && typeof error === 'object' && closedRunnerErrors.has(error)
|
|
20
|
+
const closedError = error => { closedRunnerErrors.add(error); return error }
|
|
16
21
|
const { StringDecoder } = require('node:string_decoder')
|
|
17
22
|
const { IS_WIN, resolveSpawn } = require('./winexec')
|
|
18
23
|
const { tagError } = require('./errtag') // 出错原话 → quota / sessionMissing / transient 之一(优先级只在那一处定),与 claude runner 共用
|
|
@@ -398,11 +403,11 @@ function runOnce(opts, effort) {
|
|
|
398
403
|
|
|
399
404
|
const completeClose = (code) => {
|
|
400
405
|
if (onDurableEvent && code !== 0 && !lastResult && !aborted) {
|
|
401
|
-
done(() => reject(new Error(`codex exited ${code} before a durable result`)))
|
|
406
|
+
done(() => reject(closedError(new Error(`codex exited ${code} before a durable result`))))
|
|
402
407
|
return
|
|
403
408
|
}
|
|
404
409
|
if (durableStart && !acceptedStart && !aborted && !acceptanceFailed) {
|
|
405
|
-
done(() => reject(new Error('Codex exited before turn.started acceptance')))
|
|
410
|
+
done(() => reject(closedError(new Error('Codex exited before turn.started acceptance'))))
|
|
406
411
|
return
|
|
407
412
|
}
|
|
408
413
|
// Watch the answer→close (rollout-flush) lag. Healthy = ~2–3s (real-machine verified),
|
|
@@ -450,4 +455,4 @@ function probeVersion(bin = 'codex') {
|
|
|
450
455
|
})
|
|
451
456
|
}
|
|
452
457
|
|
|
453
|
-
module.exports = { runTurn, runOnce, isClosedRunnerResult, probeVersion, normalizeEvent, parseCodexError, isEffortRejection, sandboxMode, buildArgs, buildPrompt }
|
|
458
|
+
module.exports = { runTurn, runOnce, isClosedRunnerResult, isClosedRunnerError, probeVersion, normalizeEvent, parseCodexError, isEffortRejection, sandboxMode, buildArgs, buildPrompt }
|
package/src/c2configcontrol.js
CHANGED
|
@@ -17,10 +17,11 @@ const RESET_KEYS = ['provider', 'model', 'workdir', 'codexApp']
|
|
|
17
17
|
// model, project or backend clears its session before the new configuration
|
|
18
18
|
// is written, so no later turn can resume a session from another backend.
|
|
19
19
|
// It never moves a journal, settles a receipt or reconnects the platform.
|
|
20
|
-
function createC2ConfigControl({ installation, getBindings,
|
|
21
|
-
store = null, activateRoute = null, probeRunner = assertRunnerExecutable,
|
|
20
|
+
function createC2ConfigControl({ installation, getBindings, getHeld = () => new Map(),
|
|
21
|
+
assertWriterLock, store = null, activateRoute = null, probeRunner = assertRunnerExecutable,
|
|
22
22
|
log = () => {} } = {}) {
|
|
23
23
|
if (!installation?.cfg?._path || typeof getBindings !== 'function' ||
|
|
24
|
+
typeof getHeld !== 'function' ||
|
|
24
25
|
typeof assertWriterLock !== 'function' ||
|
|
25
26
|
store !== null && typeof store.resetGroupSession !== 'function' ||
|
|
26
27
|
activateRoute !== null && typeof activateRoute !== 'function' ||
|
|
@@ -72,11 +73,19 @@ function createC2ConfigControl({ installation, getBindings, assertWriterLock,
|
|
|
72
73
|
throw new Error('Changing global concurrency requires a service restart')
|
|
73
74
|
}
|
|
74
75
|
const resets = [], activations = [], changed = []
|
|
76
|
+
const heldGroups = getHeld()
|
|
75
77
|
for (const route of routes) {
|
|
76
78
|
const target = next.routes.find(item => item.chatId === route.chatId)
|
|
77
79
|
if (route.group.platform !== target.group.platform) {
|
|
78
80
|
throw new Error('Moving a group to another platform requires an explicit state migration')
|
|
79
81
|
}
|
|
82
|
+
if (heldGroups.has(route.chatId)) {
|
|
83
|
+
// Edits elsewhere proceed; the held group keeps its exact settings.
|
|
84
|
+
if (!isDeepStrictEqual(route.group, target.group)) {
|
|
85
|
+
throw new Error('This group is held after an unresolved fault; seal its incident and restart before changing it')
|
|
86
|
+
}
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
80
89
|
if (!isDeepStrictEqual(route.group, target.group)) changed.push(route.chatId)
|
|
81
90
|
if (RESET_KEYS.some(key => !isDeepStrictEqual(route.group[key], target.group[key]))) {
|
|
82
91
|
if (!store) throw new Error('Changing model or project requires session reset authority')
|
package/src/c2console.js
CHANGED
|
@@ -20,9 +20,9 @@ const WRITE_PATHS = ['/api/login', '/api/logout', '/api/logout/all',
|
|
|
20
20
|
'/api/inbox/dismiss', '/api/issues/status']
|
|
21
21
|
const SUPPORTED_RUNNERS = ['claude', 'codex']
|
|
22
22
|
|
|
23
|
-
function createC2ConsoleFacade({ getBindings, getCapacity, getConfig,
|
|
23
|
+
function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld = () => new Map(),
|
|
24
24
|
assertWriterLock, validateConfig, applyConfig, beforeWrite = () => true, now = Date.now } = {}) {
|
|
25
|
-
for (const callback of [getBindings, getCapacity, getConfig, assertWriterLock,
|
|
25
|
+
for (const callback of [getBindings, getCapacity, getConfig, getHeld, assertWriterLock,
|
|
26
26
|
validateConfig, applyConfig, beforeWrite, now]) {
|
|
27
27
|
if (typeof callback !== 'function') throw new TypeError('C2 console needs live authority and configuration hooks')
|
|
28
28
|
}
|
|
@@ -45,10 +45,12 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig,
|
|
|
45
45
|
function getLiveState() {
|
|
46
46
|
const cfg = getConfig()
|
|
47
47
|
const capacity = getCapacity()
|
|
48
|
+
const heldGroups = getHeld()
|
|
48
49
|
const groups = {}
|
|
49
50
|
let queue = 0
|
|
50
51
|
for (const [chatId, group] of Object.entries(cfg.groups || {})) {
|
|
51
52
|
const binding = bindings().get(chatId)
|
|
53
|
+
const hold = heldGroups.get(chatId)
|
|
52
54
|
const rt = binding?.engine?.rts.get(chatId)
|
|
53
55
|
const snapshot = binding?.journal?.snapshot()
|
|
54
56
|
const pending = snapshot?.receipts?.pending || []
|
|
@@ -63,14 +65,17 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig,
|
|
|
63
65
|
waitingForCapacity: !!rt?.v2AdmissionPending,
|
|
64
66
|
sinceMs: rt?.v2StartedAt ? Math.max(0, now() - rt.v2StartedAt) : 0,
|
|
65
67
|
sessionId: binding?.engine?.store?.getSession(chatId) || '',
|
|
68
|
+
// A held group needs the operator: its last result is unknown.
|
|
69
|
+
...(hold ? { held: true, heldReason: hold.reason, heldSinceMs: Math.max(0, now() - hold.since) } : {}),
|
|
66
70
|
}
|
|
67
71
|
}
|
|
68
72
|
const activeBindingsReady = Object.entries(cfg.groups || {})
|
|
69
|
-
.filter(([, group]) => !group.paused
|
|
73
|
+
.filter(([chatId, group]) => !group.paused && !heldGroups.has(chatId))
|
|
74
|
+
.every(([chatId]) => bindings().has(chatId))
|
|
70
75
|
return { running: assertWriterLock() === true && !capacity.closed && activeBindingsReady,
|
|
71
76
|
uptimeMs: Math.max(0, now() - startedAt),
|
|
72
77
|
concurrency: { active: capacity.active, max: capacity.maxConcurrent },
|
|
73
|
-
queue, waiting: capacity.waiting, groups }
|
|
78
|
+
queue, waiting: capacity.waiting, held: heldGroups.size, groups }
|
|
74
79
|
}
|
|
75
80
|
function getActivity() {
|
|
76
81
|
const cfg = getConfig(), items = []
|
|
@@ -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('
|
|
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,8 @@ 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 reasonOf = error => String(error?.message || error || 'unknown').slice(0, 300)
|
|
7
9
|
|
|
8
10
|
// Turns a group binding still holds: a running turn, a non-idle handoff or a
|
|
9
11
|
// busy lifecycle. Scoped to one group's own journal.
|
|
@@ -23,12 +25,20 @@ function activeTurns(snapshot) {
|
|
|
23
25
|
return turns.sort((a, b) => a.chatId.localeCompare(b.chatId))
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
const runtimesOf = binding => binding?.engine?.rts instanceof Map ? [...binding.engine.rts.values()] : []
|
|
29
|
+
|
|
26
30
|
// Process-local organization only. Each binding owns a distinct durable
|
|
27
31
|
// authority; the transport and writer lease belong to this single process.
|
|
32
|
+
// A fault that only one group's journal can explain holds that group: its
|
|
33
|
+
// sealed binding stays as evidence, nothing is replayed or retried, and every
|
|
34
|
+
// other group keeps serving. Only a shared resource (writer lease, transport,
|
|
35
|
+
// model capacity) stops the process. `holds` lists groups whose journal could
|
|
36
|
+
// not be opened before the service was created.
|
|
28
37
|
function createC2MultiGroupService({ routes, assertWriterLock,
|
|
29
38
|
createTransport, createBinding, onFatal = () => {}, closeCapacity = () => {},
|
|
30
39
|
quarantine = null, stopTimeoutMs = 2_000, drainTimeoutMs = 12_000,
|
|
31
|
-
createRecoveryAuthority = null,
|
|
40
|
+
createRecoveryAuthority = null, holds = new Map(), sharedFault = () => null,
|
|
41
|
+
now = Date.now, log = () => {} } = {}) {
|
|
32
42
|
if (!Array.isArray(routes) || !routes.length ||
|
|
33
43
|
new Set(routes.map(route => route.chatId)).size !== routes.length ||
|
|
34
44
|
routes.some(route => !route || typeof route.chatId !== 'string' ||
|
|
@@ -36,7 +46,9 @@ function createC2MultiGroupService({ routes, assertWriterLock,
|
|
|
36
46
|
typeof assertWriterLock !== 'function' ||
|
|
37
47
|
typeof createTransport !== 'function' || typeof createBinding !== 'function' ||
|
|
38
48
|
typeof onFatal !== 'function' || typeof closeCapacity !== 'function' ||
|
|
39
|
-
typeof log !== 'function' ||
|
|
49
|
+
typeof log !== 'function' || typeof sharedFault !== 'function' ||
|
|
50
|
+
typeof now !== 'function' || !(holds instanceof Map) ||
|
|
51
|
+
[...holds.keys()].some(chatId => !routes.some(route => route.chatId === chatId)) ||
|
|
40
52
|
createRecoveryAuthority !== null && typeof createRecoveryAuthority !== 'function' ||
|
|
41
53
|
!Number.isSafeInteger(stopTimeoutMs) || stopTimeoutMs < 1 ||
|
|
42
54
|
!Number.isSafeInteger(drainTimeoutMs) || drainTimeoutMs < 1) {
|
|
@@ -44,6 +56,8 @@ function createC2MultiGroupService({ routes, assertWriterLock,
|
|
|
44
56
|
}
|
|
45
57
|
const configured = new Map(routes.map(route => [route.chatId, route]))
|
|
46
58
|
const bindings = new Map()
|
|
59
|
+
// chatId -> { reason, source: 'startup' | 'runtime', since, binding }
|
|
60
|
+
const suspended = new Map()
|
|
47
61
|
let transport = null
|
|
48
62
|
let started = false
|
|
49
63
|
let starting = false
|
|
@@ -92,7 +106,8 @@ function createC2MultiGroupService({ routes, assertWriterLock,
|
|
|
92
106
|
() => transport.stopPlatform(), stopTimeoutMs) === true
|
|
93
107
|
const drained = (await Promise.all([...bindings.values()].map(binding =>
|
|
94
108
|
bounded(() => binding.engine.drainDurableV2(drainTimeoutMs), drainTimeoutMs + 100))))
|
|
95
|
-
.every(value => value === true)
|
|
109
|
+
.every(value => value === true) && [...suspended.values()].every(entry =>
|
|
110
|
+
runtimesOf(entry.binding).every(rt => !rt.v2Controller))
|
|
96
111
|
return Object.freeze({ reason, code: code === 0 && startupStopped && platformStopped && drained ? 0 : 1,
|
|
97
112
|
startupStopped, platformStopped, drained })
|
|
98
113
|
})()
|
|
@@ -105,6 +120,22 @@ function createC2MultiGroupService({ routes, assertWriterLock,
|
|
|
105
120
|
await onFatal(error, result)
|
|
106
121
|
return result
|
|
107
122
|
}
|
|
123
|
+
// Seal one group in place. Its binding and journal are kept unchanged as
|
|
124
|
+
// evidence; a later restart reopens it only through the normal recovery
|
|
125
|
+
// gate, or after its incident has been sealed by the operator.
|
|
126
|
+
function suspend(chatId, error, { source, binding = null }) {
|
|
127
|
+
if (suspended.has(chatId)) return suspended.get(chatId)
|
|
128
|
+
const entry = Object.freeze({ reason: reasonOf(error), source, since: now(), binding })
|
|
129
|
+
suspended.set(chatId, entry)
|
|
130
|
+
for (const sealed of new Set([binding, bindings.get(chatId)])) {
|
|
131
|
+
if (!sealed) continue
|
|
132
|
+
try { sealed.gate.stop() } catch {}
|
|
133
|
+
try { sealed.engine.shutdown() } catch {}
|
|
134
|
+
}
|
|
135
|
+
bindings.delete(chatId)
|
|
136
|
+
log(`group ${chatId}: held (${entry.reason}); other groups keep serving`)
|
|
137
|
+
return entry
|
|
138
|
+
}
|
|
108
139
|
function allowed(chatId) {
|
|
109
140
|
const route = configured.get(chatId)
|
|
110
141
|
if (!route || route.paused === true) return false
|
|
@@ -155,6 +186,34 @@ function createC2MultiGroupService({ routes, assertWriterLock,
|
|
|
155
186
|
work.then(() => recovering.delete(chatId), () => recovering.delete(chatId))
|
|
156
187
|
return work
|
|
157
188
|
}
|
|
189
|
+
// One group's route, recall or ingress failed. Close the failed turn when
|
|
190
|
+
// its journal proves every effect; otherwise hold the group alone. Returns
|
|
191
|
+
// 'recovered', 'held', 'fatal' (process stop begun) or 'stopping'.
|
|
192
|
+
async function groupFault(chatId, binding, error) {
|
|
193
|
+
const shared = () => !writerHeld() ? 'writer lock lost' : sharedFault()
|
|
194
|
+
if (shared()) { await fatal(error); return 'fatal' }
|
|
195
|
+
if (!started || stopPromise) return 'stopping'
|
|
196
|
+
if (suspended.has(chatId)) return 'held'
|
|
197
|
+
const current = bindings.get(chatId)
|
|
198
|
+
if (!binding || current !== binding && !failedBindings.has(binding)) {
|
|
199
|
+
// An error from a binding this service never published is not attributable.
|
|
200
|
+
await fatal(error); return 'fatal'
|
|
201
|
+
}
|
|
202
|
+
if (!createRecoveryAuthority) { await fatal(error); return 'fatal' }
|
|
203
|
+
try {
|
|
204
|
+
await recoverGroup(chatId, binding, error)
|
|
205
|
+
} catch (failure) {
|
|
206
|
+
log(`group ${chatId}: failed turn could not be closed: ${reasonOf(failure)}`)
|
|
207
|
+
if (shared()) { await fatal(failure); return 'fatal' }
|
|
208
|
+
if (!started || stopPromise) return 'stopping'
|
|
209
|
+
suspend(chatId, failure, { source: 'runtime', binding })
|
|
210
|
+
}
|
|
211
|
+
return suspended.has(chatId) ? 'held' : 'recovered'
|
|
212
|
+
}
|
|
213
|
+
function heldReply(msg, sourceAdapter) {
|
|
214
|
+
return answerC2InputRejection({ error: rejectC2Input('C2 group held',
|
|
215
|
+
{ reason: 'group-held', notice: HELD_NOTICE }), msg, platform: sourceAdapter, log })
|
|
216
|
+
}
|
|
158
217
|
// Open one more group on the live transport: a newly added route or one
|
|
159
218
|
// paused since startup. It runs the same recovery gate as startup; only a
|
|
160
219
|
// fully opened binding is published. Failure leaves the group unserved.
|
|
@@ -162,13 +221,16 @@ function createC2MultiGroupService({ routes, assertWriterLock,
|
|
|
162
221
|
if (!route || typeof route.chatId !== 'string' || typeof route.file !== 'string') {
|
|
163
222
|
return Promise.reject(new TypeError('C2 online route needs an explicit group and journal'))
|
|
164
223
|
}
|
|
224
|
+
if (suspended.has(route.chatId)) {
|
|
225
|
+
return Promise.reject(new Error('This group is held after an unresolved fault; seal its incident and restart'))
|
|
226
|
+
}
|
|
165
227
|
if (bindings.has(route.chatId)) return Promise.resolve(bindings.get(route.chatId))
|
|
166
228
|
if (activating.has(route.chatId)) return activating.get(route.chatId)
|
|
167
229
|
const opening = (async () => {
|
|
168
230
|
if (!started || starting || stopPromise) throw new Error('C2 service is not serving')
|
|
169
231
|
held()
|
|
170
232
|
const binding = await createBinding({ route, transport })
|
|
171
|
-
if (!started || stopPromise || !writerHeld() ||
|
|
233
|
+
if (!started || stopPromise || !writerHeld() || suspended.has(route.chatId) ||
|
|
172
234
|
!binding || !binding.gate || !binding.engine || binding.gate.blocked) {
|
|
173
235
|
binding?.gate?.stop?.()
|
|
174
236
|
binding?.engine?.shutdown?.()
|
|
@@ -184,6 +246,12 @@ function createC2MultiGroupService({ routes, assertWriterLock,
|
|
|
184
246
|
return opening
|
|
185
247
|
}
|
|
186
248
|
function routeMessage(msg, sourceAdapter) {
|
|
249
|
+
const route = configured.get(msg?.chatId)
|
|
250
|
+
if (route && route.paused !== true && suspended.has(msg.chatId)) {
|
|
251
|
+
// A held group admits nothing; a replay of a sealed source stays silent.
|
|
252
|
+
if (quarantine?.rejects(msg)) return { quarantined: true }
|
|
253
|
+
if (started && !stopPromise) return heldReply(msg, sourceAdapter)
|
|
254
|
+
}
|
|
187
255
|
// Foreign/paused group events are not faults and never get a receipt.
|
|
188
256
|
if (!allowed(msg?.chatId)) return { ignored: true, reason: 'unconfigured-or-paused' }
|
|
189
257
|
if (quarantine?.rejects(msg)) return { quarantined: true }
|
|
@@ -192,32 +260,46 @@ function createC2MultiGroupService({ routes, assertWriterLock,
|
|
|
192
260
|
return answerC2InputRejection({ error: rejectC2Input('C2 group recovering',
|
|
193
261
|
{ reason: 'group-recovering', notice: RECOVERING_NOTICE }), msg, platform: sourceAdapter, log })
|
|
194
262
|
}
|
|
263
|
+
let binding
|
|
195
264
|
try {
|
|
196
265
|
held()
|
|
197
|
-
|
|
198
|
-
if (!started || starting || stopPromise || !binding
|
|
266
|
+
binding = bindings.get(msg.chatId)
|
|
267
|
+
if (!started || starting || stopPromise || !binding) {
|
|
199
268
|
throw new Error('C2 group ingress authority closed')
|
|
200
269
|
}
|
|
270
|
+
if (binding.gate.blocked) {
|
|
271
|
+
// A turn still running under a closed authority delivers its own
|
|
272
|
+
// rejection; until then this message, which has no receipt, is
|
|
273
|
+
// refused. An idle closed group is closed as its own fault here.
|
|
274
|
+
if (runtimesOf(binding).some(rt => rt.v2NormalRouteActive)) {
|
|
275
|
+
return answerC2InputRejection({ error: rejectC2Input('C2 group closing',
|
|
276
|
+
{ reason: 'group-recovering', notice: RECOVERING_NOTICE }), msg, platform: sourceAdapter, log })
|
|
277
|
+
}
|
|
278
|
+
return groupFault(msg.chatId, binding, new Error('C2 group ingress authority closed'))
|
|
279
|
+
.then(async outcome => {
|
|
280
|
+
if (outcome === 'held') return heldReply(msg, sourceAdapter)
|
|
281
|
+
if (outcome === 'recovered') {
|
|
282
|
+
return answerC2InputRejection({ error: rejectC2Input('C2 group recovered',
|
|
283
|
+
{ reason: 'group-recovering', notice: RECOVERING_NOTICE }), msg, platform: sourceAdapter, log })
|
|
284
|
+
}
|
|
285
|
+
throw new Error('C2 group ingress authority closed')
|
|
286
|
+
})
|
|
287
|
+
}
|
|
201
288
|
const running = binding.engine.runDurableV2Message(msg, sourceAdapter)
|
|
202
289
|
return Promise.resolve(running).catch(async error => {
|
|
203
290
|
// A refused command or premature /stop wrote nothing durable. Answer it
|
|
204
|
-
// once and keep every group serving
|
|
291
|
+
// once and keep every group serving.
|
|
205
292
|
if (isC2InputRejection(error) && started && !stopPromise &&
|
|
206
293
|
!binding.gate.blocked && writerHeld()) {
|
|
207
294
|
log(`group ${msg.chatId}: refused input before receipt (${error.reason})`)
|
|
208
295
|
return answerC2InputRejection({ error, msg, platform: sourceAdapter, log })
|
|
209
296
|
}
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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' }
|
|
297
|
+
// Any other route error belongs to this group alone unless a shared
|
|
298
|
+
// resource failed. Errors from a superseded binding join its fault.
|
|
299
|
+
if (started && !stopPromise) {
|
|
300
|
+
const outcome = await groupFault(msg.chatId, binding, error)
|
|
301
|
+
if (outcome === 'recovered') return { recovered: true, reason: 'group-fault-closed' }
|
|
302
|
+
if (outcome === 'held') return { held: true, reason: 'group-held' }
|
|
221
303
|
}
|
|
222
304
|
await fatal(error)
|
|
223
305
|
throw error
|
|
@@ -236,15 +318,22 @@ function createC2MultiGroupService({ routes, assertWriterLock,
|
|
|
236
318
|
}
|
|
237
319
|
}
|
|
238
320
|
function recall(event) {
|
|
321
|
+
if (suspended.has(event?.chatId)) return { ignored: true, reason: 'group-held' }
|
|
239
322
|
if (!allowed(event?.chatId)) return { ignored: true }
|
|
323
|
+
let binding
|
|
240
324
|
try {
|
|
241
325
|
held()
|
|
242
|
-
|
|
326
|
+
binding = bindings.get(event.chatId)
|
|
243
327
|
if (!started || stopPromise || !binding) return { ignored: true, reason: 'ingress-closed' }
|
|
244
328
|
const handled = binding.engine.handleRecall?.(event) === true
|
|
245
329
|
if (binding.runtime?.blocked) throw new Error('Recall closed its durable authority')
|
|
246
330
|
return handled ? { handled: true } : { ignored: true, reason: 'unsupported-or-inactive-recall' }
|
|
247
|
-
} catch (error) {
|
|
331
|
+
} catch (error) {
|
|
332
|
+
if (!binding || !writerHeld() || !started || stopPromise) return fatal(error)
|
|
333
|
+
return groupFault(event.chatId, binding, error).then(outcome =>
|
|
334
|
+
outcome === 'recovered' || outcome === 'held'
|
|
335
|
+
? { ignored: true, reason: `group-${outcome}` } : fatal(error))
|
|
336
|
+
}
|
|
248
337
|
}
|
|
249
338
|
async function start({ preflightOnly = false } = {}) {
|
|
250
339
|
if (started || starting || stopPromise) throw new Error('C2 service already started or stopped')
|
|
@@ -260,9 +349,19 @@ function createC2MultiGroupService({ routes, assertWriterLock,
|
|
|
260
349
|
typeof transport.stopPlatform !== 'function') {
|
|
261
350
|
throw new TypeError('C2 transport lacks a single start/stop boundary')
|
|
262
351
|
}
|
|
352
|
+
for (const [chatId, reason] of holds) suspend(chatId, reason, { source: 'startup' })
|
|
263
353
|
for (const route of routes) {
|
|
264
|
-
if (route.paused) continue //
|
|
265
|
-
|
|
354
|
+
if (route.paused || suspended.has(route.chatId)) continue // Paused journals are audited before start.
|
|
355
|
+
let binding
|
|
356
|
+
try {
|
|
357
|
+
binding = await createBinding({ route, transport })
|
|
358
|
+
} catch (error) {
|
|
359
|
+
held()
|
|
360
|
+
if (stopPromise) throw new Error('C2 service stopped during group recovery')
|
|
361
|
+
// This group's journal could not be recovered; it alone stays closed.
|
|
362
|
+
suspend(route.chatId, error, { source: 'startup' })
|
|
363
|
+
continue
|
|
364
|
+
}
|
|
266
365
|
held()
|
|
267
366
|
if (stopPromise) {
|
|
268
367
|
binding?.gate?.stop?.()
|
|
@@ -273,11 +372,19 @@ function createC2MultiGroupService({ routes, assertWriterLock,
|
|
|
273
372
|
if (!binding || !binding.gate || !binding.engine || binding.gate.blocked) {
|
|
274
373
|
binding?.gate?.stop?.()
|
|
275
374
|
binding?.engine?.shutdown?.()
|
|
276
|
-
|
|
375
|
+
suspend(route.chatId, 'C2 group binding did not open its durable authority', { source: 'startup' })
|
|
376
|
+
continue
|
|
277
377
|
}
|
|
278
378
|
bindings.set(route.chatId, binding)
|
|
279
379
|
}
|
|
280
380
|
held()
|
|
381
|
+
const heldList = [...suspended].map(([chatId, entry]) => `${chatId}: ${entry.reason}`)
|
|
382
|
+
if (preflightOnly && heldList.length) {
|
|
383
|
+
throw new Error(`Startup preflight would hold ${heldList.length} group(s): ${heldList.join('; ')}`)
|
|
384
|
+
}
|
|
385
|
+
if (heldList.length && !bindings.size) {
|
|
386
|
+
throw new Error(`Every active group is held: ${heldList.join('; ')}`)
|
|
387
|
+
}
|
|
281
388
|
if (preflightOnly) {
|
|
282
389
|
finishStartup()
|
|
283
390
|
await stop('preflight', 0)
|
|
@@ -291,14 +398,21 @@ function createC2MultiGroupService({ routes, assertWriterLock,
|
|
|
291
398
|
onCardAction: unsupported('card action'), onBoardUpdated: unsupported('board update') })
|
|
292
399
|
held()
|
|
293
400
|
if (stopPromise) throw new Error('C2 service stopped during connection')
|
|
294
|
-
return { groups: bindings.size, connected: true }
|
|
401
|
+
return { groups: bindings.size, connected: true, ...(suspended.size ? { held: heldGroups() } : {}) }
|
|
295
402
|
} catch (error) {
|
|
296
403
|
finishStartup()
|
|
297
404
|
await stop('startup-failed', 1)
|
|
298
405
|
throw error
|
|
299
406
|
} finally { starting = false; finishStartup() }
|
|
300
407
|
}
|
|
301
|
-
|
|
408
|
+
function heldGroups() {
|
|
409
|
+
return [...suspended].map(([chatId, entry]) => Object.freeze({ chatId,
|
|
410
|
+
reason: entry.reason, source: entry.source, since: entry.since }))
|
|
411
|
+
}
|
|
412
|
+
return { start, stop, routeMessage, addRoute, heldGroups,
|
|
413
|
+
get bindings() { return new Map(bindings) },
|
|
414
|
+
get held() { return new Map([...suspended].map(([chatId, entry]) =>
|
|
415
|
+
[chatId, { reason: entry.reason, source: entry.source, since: entry.since }])) } }
|
|
302
416
|
}
|
|
303
417
|
|
|
304
418
|
module.exports = { createC2MultiGroupService }
|
|
@@ -29,14 +29,21 @@ function createC2V2SourceQuarantine(value, { required = false } = {}) {
|
|
|
29
29
|
|
|
30
30
|
// Every sealed incident under <data>/incidents/*/incident.json keeps its
|
|
31
31
|
// unsettled source messages out of admission for that group, on every start.
|
|
32
|
-
// A
|
|
32
|
+
// A seal written by `sintbot incident seal` also lists the receipts its
|
|
33
|
+
// journal had already settled, so the group's new journal never runs one of
|
|
34
|
+
// those messages again. A malformed seal stops startup: an unreadable fence
|
|
35
|
+
// cannot be skipped.
|
|
33
36
|
const CHAT_ID = /^oc_[A-Za-z0-9_-]{1,124}$/
|
|
37
|
+
const RECEIPT_ID = /^[0-9a-f]{32}$/
|
|
34
38
|
function loadC2IncidentQuarantine(stateDir, { fs = require('node:fs'), path = require('node:path') } = {}) {
|
|
35
39
|
if (typeof stateDir !== 'string' || !path.isAbsolute(stateDir)) {
|
|
36
40
|
throw new TypeError('C2 incident quarantine needs the installation directory')
|
|
37
41
|
}
|
|
38
42
|
const root = path.join(stateDir, 'incidents')
|
|
43
|
+
// Loaded here so the source quarantine above stays a standalone module.
|
|
44
|
+
const { taskId } = require('./enginecheckpoint')
|
|
39
45
|
const byChat = new Map()
|
|
46
|
+
const settled = new Map()
|
|
40
47
|
const fail = name => { throw new Error(`Sealed incident record is invalid: ${name}; preserve it and review`) }
|
|
41
48
|
let entries = []
|
|
42
49
|
try { entries = fs.readdirSync(root, { withFileTypes: true }) }
|
|
@@ -53,14 +60,26 @@ function loadC2IncidentQuarantine(stateDir, { fs = require('node:fs'), path = re
|
|
|
53
60
|
try { record = JSON.parse(fs.readFileSync(file, 'utf8')) } catch { fail(entry.name) }
|
|
54
61
|
if (record?.schema !== 'sbot-c2-incident-seal-v1' || !CHAT_ID.test(record.chatId || '') ||
|
|
55
62
|
!Array.isArray(record.pending) ||
|
|
56
|
-
record.pending.some(item => !FEISHU_MESSAGE_ID.test(item?.sourceId || ''))
|
|
63
|
+
record.pending.some(item => !FEISHU_MESSAGE_ID.test(item?.sourceId || '')) ||
|
|
64
|
+
record.completedReceiptIds !== undefined && (!Array.isArray(record.completedReceiptIds) ||
|
|
65
|
+
record.completedReceiptIds.some(id => typeof id !== 'string' || !RECEIPT_ID.test(id)))) fail(entry.name)
|
|
57
66
|
const ids = byChat.get(record.chatId) || new Set()
|
|
58
67
|
for (const item of record.pending) ids.add(item.sourceId)
|
|
59
68
|
byChat.set(record.chatId, ids)
|
|
69
|
+
if (record.completedReceiptIds?.length) {
|
|
70
|
+
const done = settled.get(record.chatId) || new Set()
|
|
71
|
+
for (const id of record.completedReceiptIds) done.add(id)
|
|
72
|
+
settled.set(record.chatId, done)
|
|
73
|
+
}
|
|
60
74
|
}
|
|
61
75
|
const sourceIds = Object.freeze([...byChat.values()].flatMap(ids => [...ids]))
|
|
76
|
+
const settledBefore = message => {
|
|
77
|
+
const done = settled.get(message?.chatId)
|
|
78
|
+
if (!done || typeof message.msgId !== 'string' || !message.msgId) return false
|
|
79
|
+
return done.has(taskId({ source: 'message', chatId: message.chatId, sourceId: message.msgId }))
|
|
80
|
+
}
|
|
62
81
|
return Object.freeze({ sourceIds,
|
|
63
|
-
rejects: message => !!byChat.get(message?.chatId)?.has(message?.msgId) })
|
|
82
|
+
rejects: message => !!byChat.get(message?.chatId)?.has(message?.msgId) || settledBefore(message) })
|
|
64
83
|
}
|
|
65
84
|
|
|
66
85
|
module.exports = { createC2V2SourceQuarantine, loadC2IncidentQuarantine }
|
package/src/core/v2engine.js
CHANGED
|
@@ -596,6 +596,7 @@ async runDurableV2Message(msg, platform) {
|
|
|
596
596
|
rt.v2NormalRouteActive = true
|
|
597
597
|
const execute = async () => {
|
|
598
598
|
let release = null
|
|
599
|
+
let failed = false
|
|
599
600
|
try {
|
|
600
601
|
let receiptId = accepted.receiptId
|
|
601
602
|
if (this.durableV2AcquireCapacity) {
|
|
@@ -628,12 +629,13 @@ async runDurableV2Message(msg, platform) {
|
|
|
628
629
|
// Seal shared grants before releasing this turn's permit. Waiting
|
|
629
630
|
// siblings must not dispatch in the microtask before the service sees
|
|
630
631
|
// this rejection and begins its process-wide stop.
|
|
632
|
+
failed = true
|
|
631
633
|
this.durableV2SealCapacity?.(error)
|
|
632
634
|
throw error
|
|
633
635
|
} finally {
|
|
634
636
|
rt.v2AdmissionPending = false
|
|
635
637
|
rt.v2AdmissionController = null
|
|
636
|
-
release
|
|
638
|
+
if (release) this._releaseDurableV2Capacity(rt, release, failed)
|
|
637
639
|
}
|
|
638
640
|
}
|
|
639
641
|
const execution = execute()
|
|
@@ -649,6 +651,24 @@ async runDurableV2Message(msg, platform) {
|
|
|
649
651
|
}
|
|
650
652
|
}
|
|
651
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
|
+
|
|
652
672
|
async _runDurableV2Steer(rt, receiptId) {
|
|
653
673
|
const authority = this.durableV2Runtime
|
|
654
674
|
const chatId = rt.chatId
|
|
@@ -1323,6 +1343,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
|
|
|
1323
1343
|
rt.v2Controller = controller
|
|
1324
1344
|
rt.v2RunnerStopPromise = null
|
|
1325
1345
|
rt.v2RunnerStopVerified = false
|
|
1346
|
+
rt.v2RunnerNotStarted = false
|
|
1326
1347
|
let streamChain = Promise.resolve()
|
|
1327
1348
|
let streamFailure = null
|
|
1328
1349
|
const enqueueFrame = event => {
|
|
@@ -1508,6 +1529,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
|
|
|
1508
1529
|
rt.v2RunnerGeneration = null
|
|
1509
1530
|
rt.v2RunningJobs = null
|
|
1510
1531
|
rt.v2DispatchEffect = null
|
|
1532
|
+
rt.v2RunnerNotStarted = true
|
|
1511
1533
|
return { effectId, accepted: false, startFailureRecovered: true }
|
|
1512
1534
|
}
|
|
1513
1535
|
if (capability && !accepted) {
|
package/src/ui/console.html
CHANGED
|
@@ -392,6 +392,7 @@ code{font-family:var(--mono);font-size:.95em} /* 中文说明里内嵌的命令/
|
|
|
392
392
|
}
|
|
393
393
|
.tag{font-family:var(--mono);font-size:10.5px;letter-spacing:.5px;padding:3px 8px;border-radius:5px;border:1px solid var(--tag-line,var(--line-hi));color:var(--tag-ink,var(--dim));background:var(--tag-bg,transparent);white-space:nowrap}
|
|
394
394
|
.tag.busy{color:var(--ember);border-color:rgba(var(--ember-rgb),.4);box-shadow:0 0 10px rgba(var(--ember-rgb),.12) inset}
|
|
395
|
+
.tag.held{color:var(--fail);border-color:rgba(var(--fail-rgb),.42);background:rgba(var(--fail-rgb),.08)}
|
|
395
396
|
.tag.idle{color:var(--ok);border-color:rgba(var(--ok-rgb),.28)}
|
|
396
397
|
.tag.pz{color:var(--muted);border-color:var(--line-hi);background:transparent} /* 暂停态:中性灰,不是错误(bad 红留给真异常) */
|
|
397
398
|
.card.gpaused{opacity:.55;transition:opacity .15s} /* 暂停群卡整体降透明度示意下线;hover 提回可读 —— 配置项不禁用,暂停期间照样可调 */
|
|
@@ -1353,9 +1354,9 @@ function updateOverviewLive(){
|
|
|
1353
1354
|
const a=$('#ovActive'); if(a)a.textContent=active;
|
|
1354
1355
|
const q=$('#ovQueue'); if(q)q.textContent=live.queue||0;
|
|
1355
1356
|
$$('#overview [data-g]').forEach(el=>{const lv=(live.groups&&live.groups[el.dataset.g])||{};
|
|
1356
|
-
el.className='tag glive '+(lv.busy?'busy':'idle');
|
|
1357
|
-
el.textContent=lv.queueFaulted?'待处理异常':lv.waitingForCapacity?'等待容量':lv.finalizing?'收尾中':(lv.busy?('busy '+(lv.sinceMs?Math.round(lv.sinceMs/1000)+'s':'')):'idle');
|
|
1358
|
-
el.title=(S.runtimeCapabilities?(lv.queueFaulted?'服务当前未开放此群':lv.waitingForCapacity?'消息已登记,等待并发名额':lv.finalizing?'正在确认发送与结账结果':lv.busy?'运行中':'空闲'):(lv.finalizing?'答案已发送 · 进程收尾中(新消息会排队)':(lv.busy?'运行中':'空闲')))+(lv.sessionId?(' · 会话 #'+String(lv.sessionId).slice(-6)):' · 无会话');});
|
|
1357
|
+
el.className='tag glive '+(lv.held?'held':lv.busy?'busy':'idle');
|
|
1358
|
+
el.textContent=lv.held?'需要处理':lv.queueFaulted?'待处理异常':lv.waitingForCapacity?'等待容量':lv.finalizing?'收尾中':(lv.busy?('busy '+(lv.sinceMs?Math.round(lv.sinceMs/1000)+'s':'')):'idle');
|
|
1359
|
+
el.title=lv.held?('上一个任务结果不明,此群已暂停,其他群照常。原因:'+(lv.heldReason||'未知')+'。处理:先重启服务,能自动收尾的会恢复;重启后仍需处理,再停止服务,运行 sintbot incident seal --group '+el.dataset.g+',然后启动。'):(S.runtimeCapabilities?(lv.queueFaulted?'服务当前未开放此群':lv.waitingForCapacity?'消息已登记,等待并发名额':lv.finalizing?'正在确认发送与结账结果':lv.busy?'运行中':'空闲'):(lv.finalizing?'答案已发送 · 进程收尾中(新消息会排队)':(lv.busy?'运行中':'空闲')))+(lv.sessionId?(' · 会话 #'+String(lv.sessionId).slice(-6)):' · 无会话');});
|
|
1359
1360
|
// Self-heal stuck 'running' rows: a done SSE frame can be lost on a reconnect gap and
|
|
1360
1361
|
// SSE has no replay, so the row would spin forever. Per-group turns are serialized, so a
|
|
1361
1362
|
// 'running' row whose group is no longer busy has finished (busy=false ⇔ the turn settled
|
|
@@ -4029,6 +4030,7 @@ updateThemeBtn();
|
|
|
4029
4030
|
/* I18N_DICT_BEGIN —— 合并翻译片段时整块替换这两个数组,不要改此标记之外的代码
|
|
4030
4031
|
⚠ 正则一律 ^…$ 整串、不加 g 标志;更具体的条目必须排在泛化条之前。 */
|
|
4031
4032
|
const I18N_EN=[
|
|
4033
|
+
["需要处理", "Needs attention"],
|
|
4032
4034
|
["当前可用操作", "Available controls"],
|
|
4033
4035
|
["当前支持多群消息、流式卡片回复、忙时跟进、停止任务、状态查看、认证管理、绑定新群,以及空闲群的模型(Codex / Claude Code)和项目目录切换。新会话、解除绑定、控制台插话、定时任务、生图、画布、预览与升级暂不可用。", "Available: group messages, streaming reply cards, busy-turn followups, stopping tasks, live status, authentication, binding new groups, and switching the model (Codex / Claude Code) or project of an idle group. New sessions, unbinding, console messages, schedules, image generation, canvas, previews and upgrades are unavailable."],
|
|
4034
4036
|
["可绑定新群,修改模型、项目目录、权限、数据输出和忙时跟进;该群须没有进行中的任务和未结记录。切模型或项目会开始新的一段对话。停止任务在「概览」。群里发 /model 也能切换模型。", "Bind new groups and change the model, project folder, permissions, data display and busy-turn followups of a group with no running task or unsettled record. Changing the model or project starts a new conversation. Stop tasks in Overview. Members can also switch models with /model in the group."],
|