sintbot 1.4.4 → 1.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.en.json +50 -0
- package/CHANGELOG.json +50 -0
- package/docs/AI-ASSISTED-INSTALL.md +2 -2
- package/docs/install.md +9 -6
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +2 -2
- package/docs/zh-CN/install.md +9 -6
- package/install.sh +1 -1
- package/package.json +1 -1
- package/src/agent/codexapp.js +49 -3
- package/src/agent/codexstatus.js +130 -5
- package/src/c2configcontrol.js +86 -10
- package/src/c2console.js +52 -4
- package/src/core/c2durableruntime.js +12 -2
- package/src/core/v2engine.js +177 -15
- package/src/ui/console.html +64 -21
- package/src/ui/serverbase.js +59 -19
package/src/c2configcontrol.js
CHANGED
|
@@ -5,12 +5,24 @@ const path = require('node:path')
|
|
|
5
5
|
const { isDeepStrictEqual } = require('node:util')
|
|
6
6
|
const { atomicWriteJSON } = require('./core/store')
|
|
7
7
|
const { loadInstallationFromRaw, assertJournalScope, assertRunnerExecutable,
|
|
8
|
-
MANIFEST, VALIDATORS } = require('./c2install')
|
|
8
|
+
MANIFEST, VALIDATORS, SUPPORTED_RUNNERS } = require('./c2install')
|
|
9
9
|
const { C2DurableJournal } = require('./core/c2durablejournal')
|
|
10
10
|
const { assertNoLegacyGroupState } = require('./c2migration')
|
|
11
11
|
|
|
12
|
-
const GLOBAL_TASK_KEYS = ['
|
|
12
|
+
const GLOBAL_TASK_KEYS = ['agent', 'timezone', 'brand']
|
|
13
13
|
const RESET_KEYS = ['provider', 'model', 'workdir', 'codexApp']
|
|
14
|
+
const EFFORT_ENV = 'CLAUDE_CODE_EFFORT_LEVEL'
|
|
15
|
+
|
|
16
|
+
// What a group's session belongs to: backend, executable, endpoint, model and
|
|
17
|
+
// the environment the backend starts with. Effort, reasoning effort, tool
|
|
18
|
+
// quality, key, image support, visibility and order apply from the next turn.
|
|
19
|
+
function sessionIdentity(provider, group) {
|
|
20
|
+
if (!provider || typeof provider !== 'object') return null
|
|
21
|
+
const env = { ...(provider.env && typeof provider.env === 'object' ? provider.env : {}) }
|
|
22
|
+
delete env[EFFORT_ENV]
|
|
23
|
+
return { runner: provider.runner, bin: provider.bin || '', baseUrl: provider.baseUrl || '',
|
|
24
|
+
model: group?.model || provider.model || '', env }
|
|
25
|
+
}
|
|
14
26
|
|
|
15
27
|
// Synchronous validation/application around one atomic configuration write.
|
|
16
28
|
// A group-scoped edit waits only for that group to settle. Changing a group's
|
|
@@ -30,22 +42,55 @@ function createC2ConfigControl({ installation, getBindings, getHeld = () => new
|
|
|
30
42
|
}
|
|
31
43
|
const { cfg, routes, stateDir } = installation
|
|
32
44
|
let prepared = null
|
|
45
|
+
let appliedResets = []
|
|
33
46
|
|
|
34
47
|
function held() {
|
|
35
48
|
if (assertWriterLock() !== true) throw new Error('Service writer authority is unavailable')
|
|
36
49
|
}
|
|
37
|
-
function assertSettled(chatId, binding
|
|
50
|
+
function assertSettled(chatId, binding,
|
|
51
|
+
message = 'Wait until the group task is settled before changing its task settings') {
|
|
38
52
|
if (!binding) return
|
|
39
53
|
if (binding.gate.blocked || binding.runtime.blocked ||
|
|
40
54
|
[...binding.engine.rts.values()].some(rt => rt.v2NormalRouteActive ||
|
|
41
55
|
rt.v2AdmissionPending || rt.v2Controller || rt.v2QueueReceiptIds?.length)) {
|
|
42
|
-
throw new Error(
|
|
56
|
+
throw new Error(message)
|
|
43
57
|
}
|
|
44
58
|
assertJournalScope(binding.journal, chatId, { requireSettled: true })
|
|
45
59
|
}
|
|
46
60
|
|
|
61
|
+
// Model library edits. A provider that is added or edited must be one the
|
|
62
|
+
// durable route can run; the default must stay a visible runnable model;
|
|
63
|
+
// a provider still used by an active group or as the default stays listed.
|
|
64
|
+
function assertProviderEdits(next, effective) {
|
|
65
|
+
const before = cfg.providers || {}, after = next.cfg.providers || {}
|
|
66
|
+
for (const [name, provider] of Object.entries(after)) {
|
|
67
|
+
if (isDeepStrictEqual(before[name], provider)) continue
|
|
68
|
+
if (!provider || typeof provider !== 'object' || !SUPPORTED_RUNNERS.includes(provider.runner)) {
|
|
69
|
+
throw new Error(`模型「${name}」的后端不受支持;只能添加或修改 Codex 或 Claude Code 模型`)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (cfg.defaultProvider !== next.cfg.defaultProvider) {
|
|
73
|
+
const chosen = after[next.cfg.defaultProvider]
|
|
74
|
+
if (!chosen || typeof chosen !== 'object' || chosen.hidden ||
|
|
75
|
+
!SUPPORTED_RUNNERS.includes(chosen.runner)) {
|
|
76
|
+
throw new Error('默认模型须是列表中未隐藏的 Codex 或 Claude Code 模型')
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for (const [name, provider] of Object.entries(before)) {
|
|
80
|
+
if (!provider || provider.hidden || (after[name] && !after[name].hidden)) continue
|
|
81
|
+
const action = after[name] ? '隐藏' : '移除'
|
|
82
|
+
if (name === cfg.defaultProvider) {
|
|
83
|
+
throw new Error(`「${name}」是默认模型,先把默认模型换成别的再${action}`)
|
|
84
|
+
}
|
|
85
|
+
const users = next.routes.filter(route => !route.paused &&
|
|
86
|
+
effective(route.chatId, route.group, next.cfg) === name).length
|
|
87
|
+
if (users) throw new Error(`「${name}」正被 ${users} 个群使用,先把这些群切到其它模型再${action}`)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
47
91
|
function validateConfig(raw) {
|
|
48
92
|
prepared = null
|
|
93
|
+
appliedResets = []
|
|
49
94
|
held()
|
|
50
95
|
if (isFrozen()) throw new Error('SintBot is upgrading; settings cannot change until it restarts')
|
|
51
96
|
const next = loadInstallationFromRaw(raw, cfg._path, { dataDir: stateDir,
|
|
@@ -67,30 +112,48 @@ function createC2ConfigControl({ installation, getBindings, getHeld = () => new
|
|
|
67
112
|
if (!isDeepStrictEqual(connectionOnly(cfg.feishu), connectionOnly(next.cfg.feishu))) {
|
|
68
113
|
throw new Error('Connection settings require a service restart')
|
|
69
114
|
}
|
|
70
|
-
for (const key of ['wecom', 'slack', 'proxy', 'projectsRoots', 'browser'
|
|
115
|
+
for (const key of ['wecom', 'slack', 'proxy', 'projectsRoots', 'browser']) {
|
|
71
116
|
if (!isDeepStrictEqual(cfg[key], next.cfg[key])) throw new Error('Connection and filesystem settings require a service restart')
|
|
72
117
|
}
|
|
73
118
|
if (cfg.agent.maxConcurrent !== next.cfg.agent.maxConcurrent) {
|
|
74
119
|
throw new Error('Changing global concurrency requires a service restart')
|
|
75
120
|
}
|
|
76
|
-
|
|
121
|
+
// The model a group runs: a runtime override first (as the engine
|
|
122
|
+
// resolves it), then the group's own provider, then the default.
|
|
123
|
+
const sessions = chatId => store || bindings.get(chatId)?.engine?.store || null
|
|
124
|
+
const effective = (chatId, group, config) =>
|
|
125
|
+
sessions(chatId)?.getProvider?.(chatId) || group.provider || config.defaultProvider
|
|
126
|
+
assertProviderEdits(next, effective)
|
|
127
|
+
const resets = [], activations = [], changed = [], moved = []
|
|
77
128
|
const heldGroups = getHeld()
|
|
78
129
|
for (const route of routes) {
|
|
79
130
|
const target = next.routes.find(item => item.chatId === route.chatId)
|
|
80
131
|
if (route.group.platform !== target.group.platform) {
|
|
81
132
|
throw new Error('Moving a group to another platform requires an explicit state migration')
|
|
82
133
|
}
|
|
134
|
+
// A model library or default change reaches every group that runs
|
|
135
|
+
// the edited provider, even though its own settings stay the same.
|
|
136
|
+
const providerMoved = !isDeepStrictEqual(
|
|
137
|
+
sessionIdentity(cfg.providers?.[effective(route.chatId, route.group, cfg)], route.group),
|
|
138
|
+
sessionIdentity(next.cfg.providers?.[effective(route.chatId, target.group, next.cfg)], target.group))
|
|
83
139
|
if (heldGroups.has(route.chatId)) {
|
|
84
140
|
// Edits elsewhere proceed; the held group keeps its exact settings.
|
|
85
141
|
if (!isDeepStrictEqual(route.group, target.group)) {
|
|
86
142
|
throw new Error('This group is held after an unresolved fault; seal its incident and restart before changing it')
|
|
87
143
|
}
|
|
144
|
+
if (providerMoved) {
|
|
145
|
+
throw new Error('有群因未解决的故障暂停处理,它用的模型、后端或端点要等处理完并重启后才能改')
|
|
146
|
+
}
|
|
88
147
|
continue
|
|
89
148
|
}
|
|
90
149
|
if (!isDeepStrictEqual(route.group, target.group)) changed.push(route.chatId)
|
|
91
|
-
|
|
150
|
+
const groupReset = RESET_KEYS.some(key => !isDeepStrictEqual(route.group[key], target.group[key]))
|
|
151
|
+
if (groupReset || providerMoved) {
|
|
92
152
|
if (!store) throw new Error('Changing model or project requires session reset authority')
|
|
93
153
|
resets.push(route.chatId)
|
|
154
|
+
if (!groupReset) moved.push(route.chatId)
|
|
155
|
+
// A reset also clears any runtime override: the group then runs
|
|
156
|
+
// its configured provider, which is the one probed here.
|
|
94
157
|
if (!target.paused) probeRunner(target.provider, next.cfg)
|
|
95
158
|
}
|
|
96
159
|
if (!bindings.has(route.chatId)) {
|
|
@@ -115,6 +178,11 @@ function createC2ConfigControl({ installation, getBindings, getHeld = () => new
|
|
|
115
178
|
const global = GLOBAL_TASK_KEYS.some(key => !isDeepStrictEqual(cfg[key], next.cfg[key]))
|
|
116
179
|
for (const [chatId, binding] of bindings) {
|
|
117
180
|
if (global || changed.includes(chatId)) assertSettled(chatId, binding)
|
|
181
|
+
else if (moved.includes(chatId)) {
|
|
182
|
+
const name = next.cfg.groups?.[chatId]?.name || chatId.slice(-8)
|
|
183
|
+
assertSettled(chatId, binding,
|
|
184
|
+
`「${name}」有任务进行中或记录未结账,等它结束后再改它用的模型、后端或端点`)
|
|
185
|
+
}
|
|
118
186
|
}
|
|
119
187
|
prepared = { raw: JSON.stringify(raw), next, resets, activations, added }
|
|
120
188
|
return true
|
|
@@ -155,9 +223,15 @@ function createC2ConfigControl({ installation, getBindings, getHeld = () => new
|
|
|
155
223
|
if (assertWriterLock() !== true || !prepared || prepared.raw !== JSON.stringify(raw)) {
|
|
156
224
|
throw new Error('Configuration was not validated against the current service')
|
|
157
225
|
}
|
|
158
|
-
const { next, activations, added } = prepared
|
|
226
|
+
const { next, activations, added, resets } = prepared
|
|
159
227
|
const global = GLOBAL_TASK_KEYS.some(key => !isDeepStrictEqual(cfg[key], next.cfg[key]))
|
|
228
|
+
// Every engine copies the model library at construction and reads it
|
|
229
|
+
// when a turn starts: a library edit must reach all of them, including
|
|
230
|
+
// busy ones (their running turn keeps the settings it started with).
|
|
231
|
+
const library = !isDeepStrictEqual(cfg.providers, next.cfg.providers) ||
|
|
232
|
+
cfg.defaultProvider !== next.cfg.defaultProvider
|
|
160
233
|
prepared = null
|
|
234
|
+
appliedResets = [...resets]
|
|
161
235
|
for (const key of Object.keys(cfg)) delete cfg[key]
|
|
162
236
|
Object.assign(cfg, next.cfg)
|
|
163
237
|
const bindings = getBindings()
|
|
@@ -166,7 +240,7 @@ function createC2ConfigControl({ installation, getBindings, getHeld = () => new
|
|
|
166
240
|
const groupChanged = !isDeepStrictEqual(route.group, target.group)
|
|
167
241
|
Object.assign(route, target)
|
|
168
242
|
const engine = bindings.get(route.chatId)?.engine
|
|
169
|
-
if (engine && (global || groupChanged)) {
|
|
243
|
+
if (engine && (global || groupChanged || library)) {
|
|
170
244
|
engine.cfg = { ...cfg, groups: { [route.chatId]: route.group } }
|
|
171
245
|
engine.tz = cfg.timezone
|
|
172
246
|
}
|
|
@@ -209,7 +283,9 @@ function createC2ConfigControl({ installation, getBindings, getHeld = () => new
|
|
|
209
283
|
})
|
|
210
284
|
}
|
|
211
285
|
|
|
212
|
-
return { validateConfig, beforeWrite, applyConfig, writeConfig, switchGroupProvider
|
|
286
|
+
return { validateConfig, beforeWrite, applyConfig, writeConfig, switchGroupProvider,
|
|
287
|
+
// Groups whose session the last applied write reset (for the console).
|
|
288
|
+
appliedResets: () => [...appliedResets] }
|
|
213
289
|
}
|
|
214
290
|
|
|
215
291
|
module.exports = { createC2ConfigControl }
|
package/src/c2console.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const path = require('node:path')
|
|
4
4
|
const { createUiServer } = require('./ui/serverbase')
|
|
5
5
|
const { START_PATH, CALLBACK_PATH } = require('./ui/feishulogin')
|
|
6
|
+
const { assertJournalScope } = require('./c2install')
|
|
6
7
|
|
|
7
8
|
// A host facade over existing v2 authorities, not a second execution engine.
|
|
8
9
|
// The allowlist applies after the existing console's authentication boundary.
|
|
@@ -10,13 +11,23 @@ const READ_PATHS = ['/api/state', '/api/events', '/api/inbox', '/api/activity',
|
|
|
10
11
|
'/api/changelog', '/api/changelog-en', '/api/codex/models', '/api/usage',
|
|
11
12
|
'/api/plugins', '/api/design/styles', '/api/design/icons', '/api/fs/listdir',
|
|
12
13
|
'/api/feishu/admin-candidates', '/api/feishu/org-users', '/api/feishu/resolve-contact',
|
|
13
|
-
'/api/feishu/chats', '/api/feishu/chat-members', '/api/update',
|
|
14
|
+
'/api/feishu/chats', '/api/feishu/chat-members', '/api/update', '/api/claude/models',
|
|
15
|
+
START_PATH, CALLBACK_PATH]
|
|
14
16
|
const WRITE_PATHS = ['/api/login', '/api/logout', '/api/logout/all',
|
|
15
17
|
'/api/ui/network', '/api/ui/public-url', '/api/ui/update-settings', '/api/feishu/admins',
|
|
16
18
|
'/api/groups', '/api/groups/provider', '/api/fs/mkdir',
|
|
17
19
|
'/api/groups/order', '/api/groups/policy', '/api/groups/redact',
|
|
18
20
|
'/api/groups/codex-steer', '/api/groups/pause', '/api/groups/stop',
|
|
21
|
+
// A fresh session only for an idle, settled group (same rule as /new).
|
|
22
|
+
'/api/groups/new', '/api/groups/interrupt',
|
|
19
23
|
'/api/agent', '/api/provider-settings', '/api/codex/verify', '/api/usage/refresh',
|
|
24
|
+
// The model library goes through the same validated writer: an edit that
|
|
25
|
+
// changes what a group runs waits for that group and starts a new session.
|
|
26
|
+
'/api/providers/add', '/api/provider-key', '/api/providers/hidden', '/api/providers/order',
|
|
27
|
+
'/api/default-provider', '/api/claude/verify',
|
|
28
|
+
// One headless trial reply outside every journal and session (like a
|
|
29
|
+
// one-off agent run); it does not take a group concurrency slot.
|
|
30
|
+
'/api/test',
|
|
20
31
|
'/api/inbox/dismiss', '/api/issues/status',
|
|
21
32
|
// Software updates: check and ignore anywhere; install only when launchd
|
|
22
33
|
// manages this exact entry (the updater refuses every other run mode).
|
|
@@ -24,9 +35,10 @@ const WRITE_PATHS = ['/api/login', '/api/logout', '/api/logout/all',
|
|
|
24
35
|
const SUPPORTED_RUNNERS = ['claude', 'codex']
|
|
25
36
|
|
|
26
37
|
function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld = () => new Map(),
|
|
27
|
-
assertWriterLock, validateConfig, applyConfig, beforeWrite = () => true,
|
|
38
|
+
assertWriterLock, validateConfig, applyConfig, beforeWrite = () => true,
|
|
39
|
+
appliedResets = () => [], now = Date.now } = {}) {
|
|
28
40
|
for (const callback of [getBindings, getCapacity, getConfig, getHeld, assertWriterLock,
|
|
29
|
-
validateConfig, applyConfig, beforeWrite, now]) {
|
|
41
|
+
validateConfig, applyConfig, beforeWrite, appliedResets, now]) {
|
|
30
42
|
if (typeof callback !== 'function') throw new TypeError('C2 console needs live authority and configuration hooks')
|
|
31
43
|
}
|
|
32
44
|
if ([validateConfig, applyConfig, beforeWrite].some(callback => callback.constructor?.name === 'AsyncFunction')) {
|
|
@@ -35,7 +47,7 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
|
|
|
35
47
|
const startedAt = now()
|
|
36
48
|
const capabilities = Object.freeze({ mode: 'durable-v2',
|
|
37
49
|
reason: '此操作尚未接入当前版本的持久处理流程,未执行。',
|
|
38
|
-
summary: '
|
|
50
|
+
summary: '实时状态、认证管理、停止任务、空闲群开新会话、绑定新群、空闲群的模型切换(Codex / Claude Code)和项目目录修改、模型的添加与设置,以及后台运行时的软件升级已接入;该群有任务或未结记录时不能修改,也不能开新会话;改模型的型号、后端或端点时,用它的群须空闲,并会开始新的一段对话。解除绑定、移除模型、控制台插话、定时任务和画布暂不可用。',
|
|
39
51
|
allowed: { GET: READ_PATHS, POST: WRITE_PATHS } })
|
|
40
52
|
function held() {
|
|
41
53
|
if (assertWriterLock() !== true) throw new Error('服务写入权限已失效,操作未执行')
|
|
@@ -45,6 +57,33 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
|
|
|
45
57
|
if (!(value instanceof Map)) throw new TypeError('C2 console bindings must be a Map')
|
|
46
58
|
return value
|
|
47
59
|
}
|
|
60
|
+
// The engine's /new guard, read from the same runtime and journal the live
|
|
61
|
+
// view uses: a running, admitted, controlled or queued turn, any receipt of
|
|
62
|
+
// this group's journal not yet settled, or an outbound effect still in
|
|
63
|
+
// flight keeps the current session.
|
|
64
|
+
function groupBusy(chatId, binding) {
|
|
65
|
+
const rt = binding.engine?.rts?.get(chatId)
|
|
66
|
+
const snapshot = binding.journal?.snapshot()
|
|
67
|
+
return !!(rt?.v2NormalRouteActive || rt?.v2AdmissionPending || rt?.v2Controller ||
|
|
68
|
+
rt?.v2QueueReceiptIds?.length || snapshot?.receipts?.pending?.length ||
|
|
69
|
+
snapshot?.state?.lanes?.queue?.[chatId]?.queue?.length ||
|
|
70
|
+
binding.gate?.blocked || binding.runtime?.blocked)
|
|
71
|
+
}
|
|
72
|
+
function clearSession(chatId) {
|
|
73
|
+
held()
|
|
74
|
+
const binding = bindings().get(chatId)
|
|
75
|
+
if (!binding) throw new Error('此群未运行,未开新会话')
|
|
76
|
+
if (binding.runtime?.closed === true) throw new Error('此群当前未开放,未开新会话')
|
|
77
|
+
const busy = '该群有任务进行中或记录未结账,先停止或等它结束再开新会话'
|
|
78
|
+
if (groupBusy(chatId, binding)) throw new Error(busy)
|
|
79
|
+
// The configuration writer's settled check as well: an effect not yet
|
|
80
|
+
// acknowledged or a notice of unknown outcome also keeps the session.
|
|
81
|
+
try { assertJournalScope(binding.journal, chatId, { requireSettled: true }) } catch (error) {
|
|
82
|
+
throw new Error(busy, { cause: error })
|
|
83
|
+
}
|
|
84
|
+
binding.engine.store.resetSessionAndStats(chatId)
|
|
85
|
+
return true
|
|
86
|
+
}
|
|
48
87
|
function getLiveState() {
|
|
49
88
|
const cfg = getConfig()
|
|
50
89
|
const capacity = getCapacity()
|
|
@@ -109,6 +148,8 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
|
|
|
109
148
|
validateConfig(raw) { held(); validateConfig(raw) },
|
|
110
149
|
beforeWrite(raw) { held(); beforeWrite(raw) },
|
|
111
150
|
applyConfig(raw) { held(); applyConfig(raw) },
|
|
151
|
+
// Groups whose session the last applied configuration write reset.
|
|
152
|
+
appliedResets: () => appliedResets(),
|
|
112
153
|
decorateState(state) {
|
|
113
154
|
state.runtimeCapabilities = capabilities
|
|
114
155
|
state.previews = (state.previews || []).filter(item => getConfig().groups?.[item.chatId])
|
|
@@ -126,6 +167,13 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
|
|
|
126
167
|
if (!binding) throw new Error('此群未运行,未执行停止')
|
|
127
168
|
return binding.engine.stop(chatId)
|
|
128
169
|
},
|
|
170
|
+
clearSession,
|
|
171
|
+
// A durable stop settles later, so a busy group is never reset here:
|
|
172
|
+
// the operator stops it first and starts the new session once idle.
|
|
173
|
+
interrupt(chatId) {
|
|
174
|
+
clearSession(chatId)
|
|
175
|
+
return { wasBusy: false }
|
|
176
|
+
},
|
|
129
177
|
},
|
|
130
178
|
}
|
|
131
179
|
}
|
|
@@ -683,9 +683,19 @@ class C2DurableRuntime {
|
|
|
683
683
|
} catch (error) { this.close(); throw error }
|
|
684
684
|
|
|
685
685
|
let phase = 'attempting'
|
|
686
|
+
// Two host witnesses prove that no model turn began: the exec child never
|
|
687
|
+
// spawned (no pid), or the App Server rejected initialize/thread before
|
|
688
|
+
// any turn/start and its child has already closed.
|
|
689
|
+
const runnerNotStartedWitness = failure => {
|
|
690
|
+
const keys = failure && typeof failure === 'object' ? Object.keys(failure).sort() : []
|
|
691
|
+
return (isDeepStrictEqual(keys, ['backend', 'code', 'pid']) &&
|
|
692
|
+
failure.backend === 'codex-exec' && failure.code === 'ENOENT' && failure.pid === null) ||
|
|
693
|
+
(isDeepStrictEqual(keys, ['backend', 'closed', 'code', 'turnStarted']) &&
|
|
694
|
+
failure.backend === 'codex-app-server' && failure.code === 'THREAD_REJECTED' &&
|
|
695
|
+
failure.turnStarted === false && failure.closed === true)
|
|
696
|
+
}
|
|
686
697
|
const onSpawnFailed = (failure) => {
|
|
687
|
-
if (phase !== 'attempting' || failure
|
|
688
|
-
failure.code !== 'ENOENT' || failure.pid !== null ||
|
|
698
|
+
if (phase !== 'attempting' || !runnerNotStartedWitness(failure) ||
|
|
689
699
|
!isDeepStrictEqual(witness.jobReceiptIds, [receiptId]) ||
|
|
690
700
|
!isDeepStrictEqual(witness.batchReceiptIds, [receiptId]) ||
|
|
691
701
|
verifyGeneration({ chatId, receiptId, turnToken, generation }) !== true ||
|
package/src/core/v2engine.js
CHANGED
|
@@ -19,6 +19,10 @@ const progress = require('./turnprogress')
|
|
|
19
19
|
const NO_REPLY = 'NO_REPLY'
|
|
20
20
|
const SYSTEM_APPENDIX = systemAppendix(NO_REPLY)
|
|
21
21
|
const V2_NORMAL_ROUTE_MISSING = Object.freeze(['complete durable v2 authority'])
|
|
22
|
+
// Permission tiers the manifest describes to the agent as read-only: 1 is
|
|
23
|
+
// the Codex read-only sandbox, 3 the fail-closed default. 0 is full access
|
|
24
|
+
// and 2 an allow-listed tool set, which may still change files.
|
|
25
|
+
const READ_ONLY_TIERS = Object.freeze([1, 3])
|
|
22
26
|
const DURABLE_V2_COMMANDS = new Set(['/help', '/status', '/model', '/new', '/hud', '/issue'])
|
|
23
27
|
function fmtWan(n) {
|
|
24
28
|
n = Number(n) || 0
|
|
@@ -33,21 +37,43 @@ function humanDuration(ms) {
|
|
|
33
37
|
if (minutes < 60) return `${minutes} 分 ${seconds % 60} 秒`
|
|
34
38
|
return `${Math.floor(minutes / 60)} 小时 ${minutes % 60} 分`
|
|
35
39
|
}
|
|
40
|
+
// Group texts name the project by its folder only, never by a machine path.
|
|
41
|
+
function projectName(group) {
|
|
42
|
+
return String(group?.workdir || '').split(/[\\/]/).filter(Boolean).pop() || '(未设)'
|
|
43
|
+
}
|
|
36
44
|
const T = {
|
|
37
45
|
queued: n => n > 1 ? `⏳ 已排队 — 当前任务结束后与前面 ${n - 1} 条一并处理` : '⏳ 已排队 — 当前任务结束后处理',
|
|
38
46
|
stopped: '已停止',
|
|
39
47
|
noActiveTask: '没有正在运行的任务',
|
|
40
|
-
unsupportedCommand: '
|
|
48
|
+
unsupportedCommand: '这个命令暂不支持。只放弃手头这件发 /skip,连排队的一起取消发 /stop;其他需求直接用文字说明。',
|
|
41
49
|
unsupportedCommandWithModel: '这个命令暂不支持,发 /help 查看可用命令;其他需求直接用文字说明。',
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
'
|
|
48
|
-
|
|
49
|
-
steer ? '
|
|
50
|
-
: '
|
|
50
|
+
// Plain text: 【】 sections and "· " items, each read in one glance, no
|
|
51
|
+
// alignment. Commands go by urgency; the three cancel commands rise in what
|
|
52
|
+
// they discard and describe the queue and the conversation in the same words.
|
|
53
|
+
help: ({ project, model, steer, readOnly }) => ['【这是什么】',
|
|
54
|
+
`· 本群绑着项目「${project}」,模型 ${model}`,
|
|
55
|
+
readOnly ? '· 直接问它,它看代码、答问题;之前聊的都记得'
|
|
56
|
+
: '· 直接说事它就干,做完在群里回你;之前聊的都记得',
|
|
57
|
+
steer ? '· 任务进行中再发的消息会补充进当前任务'
|
|
58
|
+
: '· 任务进行中再发的消息会排队,等这一轮结束再处理',
|
|
59
|
+
'',
|
|
60
|
+
'【命令】',
|
|
61
|
+
'· /status — 跑了多久、还有没有动静、排队几条',
|
|
62
|
+
'· /skip — 只放弃手头这件,排队的继续;之前聊的还记得',
|
|
63
|
+
'· /stop — 手头和排队的全取消;之前聊的还记得',
|
|
64
|
+
'· /new — 忘掉之前聊的,从头开始(要等手头任务结束)',
|
|
65
|
+
'· /model — 本群在用的模型;换:/model 名字(要等手头任务结束,换完之前聊的不再带着)',
|
|
66
|
+
'· /hud — 额度与本段对话的用量',
|
|
67
|
+
'· /issue 一句话 — 记下问题反馈,给管理员看',
|
|
68
|
+
'· /help — 这份说明',
|
|
69
|
+
// Not "trailing text is ignored": /stop with text after it is not recognised.
|
|
70
|
+
'⚠ 命令单独发一条,后面别跟别的字(/issue、/model 除外)',
|
|
71
|
+
// Same boundary as handleRecall: a queued message, or the task running on
|
|
72
|
+
// that message alone; a steered follow-up takes its whole task with it.
|
|
73
|
+
steer ? '⚠ 撤回自己发的消息 = 取消那件事;撤回补进当前任务的那条,会连当前任务一起取消'
|
|
74
|
+
: '⚠ 撤回自己发的消息 = 取消那件事(已和别的消息合成一轮在做的除外)',
|
|
75
|
+
...(readOnly ? ['⚠ 本群为只读模式:能看代码、答问题;要改文件需管理员放开权限'] : []),
|
|
76
|
+
].join('\n'),
|
|
51
77
|
status: ({ model, project, state, doing = [], queue, session }) => ['【本群状态】', `· 模型:${model}`,
|
|
52
78
|
`· 项目:${project}`, `· 状态:${state}`,
|
|
53
79
|
...(doing.length ? [`· 最近在做:${doing.join(' · ')}`] : []),
|
|
@@ -71,6 +97,9 @@ const T = {
|
|
|
71
97
|
stopPending: '正在停止当前任务,这条消息没有处理,请稍后重发。',
|
|
72
98
|
stopUnavailable: '当前任务正在收尾,暂时无法停止。',
|
|
73
99
|
stoppedWithQueue: n => `已停止 — 连同排队的 ${n} 条一并取消`,
|
|
100
|
+
skipped: n => n > 0 ? `已跳过当前任务(后面还有 ${n} 条排队,接着处理)` : '已跳过当前任务',
|
|
101
|
+
skipUnavailable: '当前任务正在收尾,暂时无法跳过。',
|
|
102
|
+
skipPending: '正在跳过当前任务,这条消息没有处理,请稍后重发。',
|
|
74
103
|
quotaFailure: 'Codex 订阅额度已用完,本次任务未完成。请恢复额度后重新发送;本次请求不会自动重试。',
|
|
75
104
|
cardFooter: ({ ms, tokens }) => {
|
|
76
105
|
const parts = [`耗时 ${Math.max(0, Math.round(ms / 1000))} 秒`]
|
|
@@ -347,10 +376,20 @@ async _durableV2Command(msg, cmd, rest) {
|
|
|
347
376
|
const group = this._resolveGroup(msg.chatId)
|
|
348
377
|
if (!group || group.paused) return reject('unsupported-command', T.unsupportedCommandWithModel)
|
|
349
378
|
const { name, provider } = this._resolveProvider(msg.chatId, group)
|
|
350
|
-
|
|
379
|
+
// A group may pin its own model; the runner uses it before the provider default.
|
|
380
|
+
const label = (n, p) => (group.model || p?.model) ? `${n} · ${group.model || p.model}` : n
|
|
351
381
|
if (cmd === '/model') return this._durableV2ModelCommand(msg, rest, group)
|
|
352
382
|
if (cmd === '/help') {
|
|
353
|
-
|
|
383
|
+
const isCodex = provider?.runner === 'codex'
|
|
384
|
+
// Read-only exactly when the manifest tells the agent so, for either
|
|
385
|
+
// runner; an unreadable policy reads as read-only.
|
|
386
|
+
let readOnly = true
|
|
387
|
+
try {
|
|
388
|
+
readOnly = READ_ONLY_TIERS.includes(manifestPolicy(group.permissionMode, isCodex,
|
|
389
|
+
group.allowedTools, group.disallowedTools).tier)
|
|
390
|
+
} catch {}
|
|
391
|
+
return reject('help', T.help({ project: projectName(group), model: label(name, provider),
|
|
392
|
+
steer: isCodex && group.codexSteer !== false, readOnly }))
|
|
354
393
|
}
|
|
355
394
|
if (cmd === '/status') {
|
|
356
395
|
const rt = this.rts.get(msg.chatId)
|
|
@@ -360,8 +399,7 @@ async _durableV2Command(msg, cmd, rest) {
|
|
|
360
399
|
const state = busy
|
|
361
400
|
? `运行中(已跑 ${humanDuration(Date.now() - (rt.v2StartedAt || Date.now()))}${rt.v2Steps ? ` · 已执行 ${rt.v2Steps} 步` : ''}${rt.v2AliveAt ? ` · 最近动静 ${humanDuration(Date.now() - rt.v2AliveAt)}前` : ''})`
|
|
362
401
|
: rt?.v2AdmissionPending ? '消息已登记,等待并发名额' : '空闲'
|
|
363
|
-
|
|
364
|
-
return reject('status', T.status({ model: label(name, provider), project, state,
|
|
402
|
+
return reject('status', T.status({ model: label(name, provider), project: projectName(group), state,
|
|
365
403
|
doing: busy && Array.isArray(rt.v2Doing) ? rt.v2Doing : [], queue: queued,
|
|
366
404
|
session: this.store.getSession(msg.chatId) ? '进行中(记着上下文)' : '未开始' }))
|
|
367
405
|
}
|
|
@@ -437,6 +475,9 @@ async runDurableV2Message(msg, platform) {
|
|
|
437
475
|
if (msg?.text?.trim() === '/stop') {
|
|
438
476
|
return this.runDurableV2Stop(msg, platform)
|
|
439
477
|
}
|
|
478
|
+
if (typeof msg?.text === 'string' && /^\/skip(?:\s|$)/.test(msg.text.trim())) {
|
|
479
|
+
throw await this.runDurableV2Skip(msg)
|
|
480
|
+
}
|
|
440
481
|
if (!this.durableV2NormalRoute) {
|
|
441
482
|
const error = new Error('C2 v2 normal route is closed: runner transcript, tool/stream effects, and terminal projection lack durable evidence')
|
|
442
483
|
error.code = 'C2_V2_NORMAL_ROUTE_CLOSED'
|
|
@@ -501,6 +542,12 @@ async runDurableV2Message(msg, platform) {
|
|
|
501
542
|
throw rejectC2Input('C2 v2 ordinary route is waiting for stop acknowledgement',
|
|
502
543
|
{ reason: 'stop-pending', notice: T.stopPending })
|
|
503
544
|
}
|
|
545
|
+
// Between /skip and its durable cancellation the runner may already be
|
|
546
|
+
// closed; refuse before receipt instead of reaching the closed busy route.
|
|
547
|
+
if (rt.v2SkipWaiter) {
|
|
548
|
+
throw rejectC2Input('C2 v2 ordinary route is waiting for /skip to settle',
|
|
549
|
+
{ reason: 'skip-pending', notice: T.skipPending })
|
|
550
|
+
}
|
|
504
551
|
const lane = before.state?.lanes
|
|
505
552
|
const queue = lane?.queue?.[msg.chatId]?.queue
|
|
506
553
|
if (rt.v2AdmissionPending) {
|
|
@@ -850,6 +897,79 @@ async runDurableV2Stop(msg, platform) {
|
|
|
850
897
|
}
|
|
851
898
|
}
|
|
852
899
|
|
|
900
|
+
// /skip cancels only the running turn, through the same durable cancellation
|
|
901
|
+
// as the console stop: queued receipts stay queued and run next, and the
|
|
902
|
+
// session is kept. Like the other chat commands it gets no receipt and is
|
|
903
|
+
// answered as an input rejection, here only after the cancellation has
|
|
904
|
+
// committed. That reply is best effort: the durable stop acknowledgement
|
|
905
|
+
// exists only for /stop, whose transition empties the queue.
|
|
906
|
+
async runDurableV2Skip(msg) {
|
|
907
|
+
const authority = this.durableV2Runtime
|
|
908
|
+
const chatId = msg?.chatId
|
|
909
|
+
if (!this.durableV2NormalRoute || !authority ||
|
|
910
|
+
!msg || msg.synthetic || msg.cron || msg.polled || msg.quiet ||
|
|
911
|
+
msg.wantsAck || msg.batch ||
|
|
912
|
+
typeof chatId !== 'string' || !chatId ||
|
|
913
|
+
typeof msg.msgId !== 'string' ||
|
|
914
|
+
!/^om_[A-Za-z0-9_-]{1,124}$/.test(msg.msgId) ||
|
|
915
|
+
msg.replyCtx?.chatId !== chatId ||
|
|
916
|
+
msg.replyCtx?.messageId !== msg.msgId) {
|
|
917
|
+
throw new Error('C2 v2 /skip is unsupported or closed')
|
|
918
|
+
}
|
|
919
|
+
const group = this._resolveGroup(chatId)
|
|
920
|
+
if (!group || group.paused || group.platform && group.platform !== 'feishu') {
|
|
921
|
+
throw new Error('C2 v2 /skip group control is not migrated')
|
|
922
|
+
}
|
|
923
|
+
// As for /stop, a /skip with nothing it may cancel is refused before receipt.
|
|
924
|
+
const reject = (reason, notice) => rejectC2Input(reason === 'skipped'
|
|
925
|
+
? 'C2 v2 /skip answered after durable cancellation'
|
|
926
|
+
: 'C2 v2 /skip is unsupported or closed', { reason, notice })
|
|
927
|
+
// Without a receipt nothing durable dedupes this command, so a platform
|
|
928
|
+
// redelivery must not cancel a later task: a repeat this process has seen
|
|
929
|
+
// is dropped silently, and so is a /skip sent before the newest message of
|
|
930
|
+
// the running task (both are platform times).
|
|
931
|
+
const seen = this.durableV2SkipSeen || (this.durableV2SkipSeen = new Set())
|
|
932
|
+
const key = `${chatId}\n${msg.msgId}`
|
|
933
|
+
if (seen.has(key)) return reject('skip-duplicate', null)
|
|
934
|
+
seen.add(key)
|
|
935
|
+
if (seen.size > 256) seen.delete(seen.values().next().value)
|
|
936
|
+
const rt = this.rts.get(chatId)
|
|
937
|
+
if (!rt?.v2NormalRouteActive || !rt.v2RunningJobs?.length) {
|
|
938
|
+
return reject('skip-idle', T.noActiveTask)
|
|
939
|
+
}
|
|
940
|
+
const sentAt = Number(msg.createTimeMs)
|
|
941
|
+
if (Number.isSafeInteger(sentAt) && sentAt > 0 && rt.v2RunningJobs.some(job =>
|
|
942
|
+
Number(job?.msg?.createTimeMs) > sentAt)) {
|
|
943
|
+
return reject('skip-stale', null)
|
|
944
|
+
}
|
|
945
|
+
const route = rt.v2NormalRoutePromise
|
|
946
|
+
let settle
|
|
947
|
+
const settled = new Promise(resolve => { settle = resolve })
|
|
948
|
+
const waiter = { turnToken: rt.v2TurnToken, settle }
|
|
949
|
+
if (rt.v2SkipWaiter || !route || !Number.isSafeInteger(waiter.turnToken)) {
|
|
950
|
+
return reject('skip-unavailable', T.skipUnavailable)
|
|
951
|
+
}
|
|
952
|
+
rt.v2SkipWaiter = waiter
|
|
953
|
+
try {
|
|
954
|
+
if (!this.requestDurableV2Stop(chatId)) {
|
|
955
|
+
if (authority.blocked) throw new Error('C2 v2 /skip closed its durable authority')
|
|
956
|
+
return reject('skip-unavailable', T.skipUnavailable)
|
|
957
|
+
}
|
|
958
|
+
const queued = await Promise.race([settled, route.then(() => null)])
|
|
959
|
+
if (queued === null) {
|
|
960
|
+
// The turn ended without this cancellation, e.g. its runner was proven
|
|
961
|
+
// never started. Nothing was skipped and the group is not at fault;
|
|
962
|
+
// only a closed authority is.
|
|
963
|
+
if (authority.blocked) throw new Error('C2 v2 /skip cancellation was not durably settled')
|
|
964
|
+
return reject('skip-unavailable', T.skipUnavailable)
|
|
965
|
+
}
|
|
966
|
+
this.log(`/skip: ${chatId} (by ${msg.senderName || 'unknown'}), ${queued} queued`)
|
|
967
|
+
return reject('skipped', T.skipped(queued))
|
|
968
|
+
} finally {
|
|
969
|
+
if (rt.v2SkipWaiter === waiter) rt.v2SkipWaiter = null
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
853
973
|
async _cleanupDurableV2CancelledCard(rt, identity, generation,
|
|
854
974
|
verifyGeneration) {
|
|
855
975
|
if (!rt.v2CardBinding?.opened) return null
|
|
@@ -963,6 +1083,13 @@ async _drainDurableV2NormalChain(initial) {
|
|
|
963
1083
|
terminalEffectId: null, cancellationEventId: cancelled.eventId })
|
|
964
1084
|
}
|
|
965
1085
|
if (authority.blocked) throw new Error('C2 v2 cancellation closed ingress')
|
|
1086
|
+
// A chat /skip answers once its own turn is durably cancelled; what
|
|
1087
|
+
// stays in the queue is the work that runs next.
|
|
1088
|
+
const skip = rt.v2SkipWaiter
|
|
1089
|
+
if (!stop && skip?.turnToken === identity.turnToken) {
|
|
1090
|
+
rt.v2SkipWaiter = null
|
|
1091
|
+
skip.settle(authority.snapshot().state.lanes.queue?.[identity.chatId]?.queue?.length || 0)
|
|
1092
|
+
}
|
|
966
1093
|
const nextReceiptId = this._nextDurableV2NormalAnchor(identity.chatId)
|
|
967
1094
|
if (nextReceiptId) {
|
|
968
1095
|
const next = this.commitDurableV2InitialDispatch({
|
|
@@ -1627,7 +1754,8 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
|
|
|
1627
1754
|
if (controller && rt.v2Controller === controller) rt.v2Controller = null
|
|
1628
1755
|
const proofId = `sbot-c2-runner-not-started-v1:${receiptId}:${generation}`
|
|
1629
1756
|
const eventId = `sbot-c2-runner-not-started-commit-v1:${receiptId}:${turnToken}:${generation}`
|
|
1630
|
-
const provenNotStarted = !accepted &&
|
|
1757
|
+
const provenNotStarted = !accepted &&
|
|
1758
|
+
['AGENT_BIN_MISSING', 'AGENT_THREAD_REJECTED'].includes(error?.code) &&
|
|
1631
1759
|
this.durableV2Journal?.lookupRunnerNotStarted(proofId)?.dispatchEffectId === effectId &&
|
|
1632
1760
|
this.durableV2Journal?.hasEvent(eventId) === true
|
|
1633
1761
|
if (provenNotStarted) {
|
|
@@ -1638,10 +1766,44 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
|
|
|
1638
1766
|
authority.close()
|
|
1639
1767
|
throw new Error('C2 v2 unstarted runner receipt remains unsettled')
|
|
1640
1768
|
}
|
|
1769
|
+
// Recovery also settles messages queued behind this turn, each with its
|
|
1770
|
+
// own confirmed notice. Forget their bodies and the local queue so the
|
|
1771
|
+
// next message is admitted as idle, not as a busy follow-up. A queue
|
|
1772
|
+
// the recovery left in place has no dispatcher here: fail closed.
|
|
1773
|
+
const durableQueue = authority.snapshot().state.lanes.queue?.[chatId]?.queue || []
|
|
1774
|
+
if (durableQueue.length) {
|
|
1775
|
+
authority.close()
|
|
1776
|
+
throw new Error('C2 v2 unstarted runner left queued receipts unsettled')
|
|
1777
|
+
}
|
|
1778
|
+
try {
|
|
1779
|
+
for (const id of rt.v2QueueReceiptIds || []) this.durableV2Payloads.dropConfirmed(id)
|
|
1780
|
+
} catch (dropError) { authority.close(); throw dropError }
|
|
1781
|
+
rt.v2QueueReceiptIds = []
|
|
1782
|
+
// A resume the server itself rejected leaves the saved thread unusable
|
|
1783
|
+
// (missing rollout, unreadable history). As /new on this now idle and
|
|
1784
|
+
// settled group: the next message starts a fresh session instead of
|
|
1785
|
+
// meeting the same rejection again.
|
|
1786
|
+
if (error.code === 'AGENT_THREAD_REJECTED' && this.store.getSession(chatId)) {
|
|
1787
|
+
let locked = false
|
|
1788
|
+
try { locked = this.durableV2AssertWriterLock?.() === true } catch {}
|
|
1789
|
+
if (!locked) {
|
|
1790
|
+
authority.close()
|
|
1791
|
+
throw new Error('C2 v2 rejected-session reset lost single-writer lock')
|
|
1792
|
+
}
|
|
1793
|
+
this.store.resetSessionAndStats(chatId)
|
|
1794
|
+
this.log(`group ${chatId}: Codex rejected the saved session before any turn; the next message starts a new session`)
|
|
1795
|
+
}
|
|
1641
1796
|
rt.v2RunnerGeneration = null
|
|
1642
1797
|
rt.v2RunningJobs = null
|
|
1643
1798
|
rt.v2DispatchEffect = null
|
|
1644
1799
|
rt.v2RunnerNotStarted = true
|
|
1800
|
+
// A console stop or /skip asked of this turn has nothing left to
|
|
1801
|
+
// cancel; carried over, it would cancel the group's next turn.
|
|
1802
|
+
if (!rt.v2StopControl) {
|
|
1803
|
+
rt.v2CancelControl = null
|
|
1804
|
+
rt.v2StreamQuiescing = false
|
|
1805
|
+
rt.v2StopIntentGate = null
|
|
1806
|
+
}
|
|
1645
1807
|
return { effectId, accepted: false, startFailureRecovered: true }
|
|
1646
1808
|
}
|
|
1647
1809
|
if (capability && !accepted) {
|