sintbot 1.4.4 → 1.4.5
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 +35 -0
- package/CHANGELOG.json +35 -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/codexstatus.js +130 -5
- package/src/c2configcontrol.js +86 -10
- package/src/c2console.js +52 -4
- package/src/core/v2engine.js +148 -14
- package/src/ui/console.html +64 -21
- package/src/ui/serverbase.js +59 -19
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
|
}
|
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({
|
|
@@ -1642,6 +1769,13 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
|
|
|
1642
1769
|
rt.v2RunningJobs = null
|
|
1643
1770
|
rt.v2DispatchEffect = null
|
|
1644
1771
|
rt.v2RunnerNotStarted = true
|
|
1772
|
+
// A console stop or /skip asked of this turn has nothing left to
|
|
1773
|
+
// cancel; carried over, it would cancel the group's next turn.
|
|
1774
|
+
if (!rt.v2StopControl) {
|
|
1775
|
+
rt.v2CancelControl = null
|
|
1776
|
+
rt.v2StreamQuiescing = false
|
|
1777
|
+
rt.v2StopIntentGate = null
|
|
1778
|
+
}
|
|
1645
1779
|
return { effectId, accepted: false, startFailureRecovered: true }
|
|
1646
1780
|
}
|
|
1647
1781
|
if (capability && !accepted) {
|