sintbot 1.4.5 → 1.4.7
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 +30 -0
- package/CHANGELOG.json +30 -0
- package/docs/AI-ASSISTED-INSTALL.md +2 -2
- package/docs/install.md +2 -2
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +2 -2
- package/docs/zh-CN/install.md +2 -2
- package/install.sh +1 -1
- package/package.json +1 -1
- package/src/agent/codexapp.js +49 -3
- package/src/c2multigroupservice.js +25 -2
- package/src/core/c2durableruntime.js +12 -2
- package/src/core/v2engine.js +29 -1
package/CHANGELOG.en.json
CHANGED
|
@@ -1,4 +1,34 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.4.7",
|
|
4
|
+
"date": "2026-11-09",
|
|
5
|
+
"name": "/stop no longer pauses the group",
|
|
6
|
+
"items": [
|
|
7
|
+
{
|
|
8
|
+
"t": "Fix: /stop during a task could pause the whole group",
|
|
9
|
+
"d": "/stop removes the task's reply card, and Feishu then reports that removal as a recall. When it arrived while \"stopped\" was still being sent, it was treated as a fault: the group was paused until an administrator released it, and later /model and /new were refused. The recall now waits for that reply to finish and the group keeps serving."
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"t": "Fix: recalling a queued message could pause the group",
|
|
13
|
+
"d": "Recalling a message while its \"queued\" notice was still being sent also paused the whole group; the message is now cancelled once the notice has been sent."
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"version": "1.4.6",
|
|
19
|
+
"date": "2026-11-09",
|
|
20
|
+
"name": "A rejected Codex resume no longer holds the group",
|
|
21
|
+
"items": [
|
|
22
|
+
{
|
|
23
|
+
"t": "Fix: a group held when Codex rejected a resume",
|
|
24
|
+
"d": "When Codex refused to continue the previous conversation before doing any work (for example a damaged Codex session history on the machine, or a conversation whose record is gone), the whole group was paused until an administrator released it. Now the group gets one \"the last turn did not finish, please send it again\" notice, keeps serving, and starts a new conversation, so resending works."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"t": "Fix: a queued message stalled the group after a start failure",
|
|
28
|
+
"d": "When the model could not start (program missing or resume rejected), messages queued behind it each get a resend notice; before, the next new message was treated as a busy group and the whole group was paused."
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
},
|
|
2
32
|
{
|
|
3
33
|
"version": "1.4.5",
|
|
4
34
|
"date": "2026-11-09",
|
package/CHANGELOG.json
CHANGED
|
@@ -1,4 +1,34 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "1.4.7",
|
|
4
|
+
"date": "2026-11-09",
|
|
5
|
+
"name": "/stop 后群不再被暂停",
|
|
6
|
+
"items": [
|
|
7
|
+
{
|
|
8
|
+
"t": "修复:任务进行中发 /stop,群可能被整个暂停",
|
|
9
|
+
"d": "/stop 会删掉这条任务的回复卡片,飞书随后把这次删除当作一次撤回通知回来;若它恰好在「已停止」还在发送时到达,此前会被当成故障,群被暂停、要管理员人工解封,后面的 /model、/new 也都不响应。现在会等那条回复发完再处理,群照常服务。"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"t": "修复:撤回排队中的消息时群可能被暂停",
|
|
13
|
+
"d": "在「已排队」提示还在发送时撤回那条消息,此前也会让整群暂停;现在等提示发完再取消那条消息。"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"version": "1.4.6",
|
|
19
|
+
"date": "2026-11-09",
|
|
20
|
+
"name": "Codex 续接被拒不再挂群",
|
|
21
|
+
"items": [
|
|
22
|
+
{
|
|
23
|
+
"t": "修复:Codex 续接被拒时整群挂起",
|
|
24
|
+
"d": "Codex 还没开始干活就拒绝接着之前的对话(比如本机 Codex 的会话历史损坏,或那段对话的记录已不存在)时,此前整个群会被暂停,要管理员人工解封。现在只回一条「上一轮没有完成,请重新发送」,群照常服务,并自动开始新的一段对话,重发即可。"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"t": "修复:任务没能启动时,排在后面的消息让群卡住",
|
|
28
|
+
"d": "模型没能启动(找不到程序或续接被拒)时,排在后面的消息也各收到一条重发提醒;此前之后的新消息会被当成「群里有任务」而整群暂停。"
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
},
|
|
2
32
|
{
|
|
3
33
|
"version": "1.4.5",
|
|
4
34
|
"date": "2026-11-09",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Install SintBot 1.4.
|
|
1
|
+
# Install SintBot 1.4.7 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.7 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.7
|
|
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.7` into `~/.local/share/sintbot`, and verifies the entry, built-in core and Feishu module. Continue when you see `Installed and verified SintBot 1.4.7`. 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
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# 用 AI 助手安装 SintBot 1.4.
|
|
1
|
+
# 用 AI 助手安装 SintBot 1.4.7
|
|
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.7。保留已有服务、配置、状态和 Codex 或 Claude Code 账号数据,不输出凭据。验证实际入口与二进制核心,配置飞书群及各群的执行器,只对全新状态初始化。运行前台预检和已授权的只读群测试,对照结果,再验证正常停止与重启。完成后停止临时进程,并区分程序包安装与群验收结果。
|
|
19
19
|
```
|
package/docs/zh-CN/install.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# 安装 SintBot 1.4.
|
|
1
|
+
# 安装 SintBot 1.4.7
|
|
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.7` 装到 `~/.local/share/sintbot`,并验证程序入口、内置核心和飞书模块。看到 `Installed and verified SintBot 1.4.7` 就可以继续。不需要 sudo。
|
|
14
14
|
|
|
15
15
|
可选预览:在命令末尾加 `-s -- --dry-run`。要自定义安装位置,设置 `SINTBOT_INSTALL_DIR` 环境变量,并同步修改后面的可执行文件路径。
|
|
16
16
|
|
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.7
|
|
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
package/src/agent/codexapp.js
CHANGED
|
@@ -66,11 +66,12 @@ function runTurn(opts = {}) {
|
|
|
66
66
|
const {
|
|
67
67
|
bin = 'codex', prompt, provider = {}, model, workdir, sessionId,
|
|
68
68
|
permissionMode, autocompactTokens, appendSystemPrompt, codexApp, codexProjectDocFallback, extraEnv,
|
|
69
|
-
onEvent, onDurableEvent, onSteerReady, onSteerLanded, onTurnAccepted, signal, log = () => {},
|
|
69
|
+
onEvent, onDurableEvent, onSteerReady, onSteerLanded, onTurnAccepted, onSpawnFailed, signal, log = () => {},
|
|
70
70
|
softStopGraceMs = 10000, spawnFn = spawn,
|
|
71
71
|
} = opts
|
|
72
72
|
if (!prompt) return Promise.reject(new Error('runTurn: prompt required'))
|
|
73
73
|
if (onDurableEvent !== undefined && typeof onDurableEvent !== 'function') return Promise.reject(new TypeError('onDurableEvent must be a function'))
|
|
74
|
+
if (onSpawnFailed !== undefined && typeof onSpawnFailed !== 'function') return Promise.reject(new TypeError('onSpawnFailed must be a function'))
|
|
74
75
|
|
|
75
76
|
return new Promise((resolve, reject) => {
|
|
76
77
|
const env = { ...process.env, ...(extraEnv || {}) }
|
|
@@ -104,6 +105,12 @@ function runTurn(opts = {}) {
|
|
|
104
105
|
let retriedEffort = false
|
|
105
106
|
let pendingTurnStart = false
|
|
106
107
|
let acceptedTurnId = null
|
|
108
|
+
// Pre-turn rejection witness: the server answered initialize or the
|
|
109
|
+
// thread request with an RPC error before any turn/start was written and
|
|
110
|
+
// before any durable event. Only the child's own close completes it.
|
|
111
|
+
let turnStartWritten = false
|
|
112
|
+
let durableEmitted = false
|
|
113
|
+
let preTurnRejection = null
|
|
107
114
|
const deferredTurnNotices = []
|
|
108
115
|
let deferredInit = null
|
|
109
116
|
const earlyCompleted = new Map()
|
|
@@ -119,6 +126,7 @@ function runTurn(opts = {}) {
|
|
|
119
126
|
const emit = (ev) => {
|
|
120
127
|
if (!ev) return
|
|
121
128
|
if (!onDurableEvent) { legacyEmit(ev); return }
|
|
129
|
+
durableEmitted = true
|
|
122
130
|
durableWork = durableWork.then(async () => {
|
|
123
131
|
let timer
|
|
124
132
|
try {
|
|
@@ -210,6 +218,37 @@ function runTurn(opts = {}) {
|
|
|
210
218
|
killChild()
|
|
211
219
|
settle(() => reject(error))
|
|
212
220
|
}
|
|
221
|
+
// A durable turn whose initialize or thread request the server itself
|
|
222
|
+
// rejected never wrote turn/start and emitted nothing, so no model turn
|
|
223
|
+
// and no tool ran. Stop reading, let the child exit, and report the
|
|
224
|
+
// narrow witness only from the child's close event.
|
|
225
|
+
const canWitnessPreTurnRejection = (error) => !!onDurableEvent && !!onTurnAccepted &&
|
|
226
|
+
!!onSpawnFailed && error?.codexRpcError === true && !turnStartWritten &&
|
|
227
|
+
!pendingTurnStart && !acceptedTurnId && !activeTurnId && !durableEmitted &&
|
|
228
|
+
!aborted && !completed && !durableFailed && !settled && !childCloseObserved
|
|
229
|
+
const rejectBeforeTurn = (error) => {
|
|
230
|
+
preTurnRejection = error
|
|
231
|
+
durableFailed = true
|
|
232
|
+
completed = true
|
|
233
|
+
deactivate()
|
|
234
|
+
try { child.stdin.end() } catch {}
|
|
235
|
+
child.stdout.resume?.()
|
|
236
|
+
killChild()
|
|
237
|
+
}
|
|
238
|
+
const witnessPreTurnRejection = () => settle(() => {
|
|
239
|
+
const message = String(preTurnRejection?.message || 'Codex RPC error')
|
|
240
|
+
let acknowledged
|
|
241
|
+
try {
|
|
242
|
+
acknowledged = onSpawnFailed({ backend: 'codex-app-server', code: 'THREAD_REJECTED',
|
|
243
|
+
turnStarted: false, closed: true })
|
|
244
|
+
} catch (witnessError) { reject(closedError(witnessError)); return }
|
|
245
|
+
if (acknowledged !== true) {
|
|
246
|
+
reject(closedError(new Error('Codex pre-turn rejection witness was not confirmed synchronously')))
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
log(`codex app-server rejected the thread before any turn started: ${message.slice(0, 300)}`)
|
|
250
|
+
reject(closedError(Object.assign(new Error(message), { code: 'AGENT_THREAD_REJECTED' })))
|
|
251
|
+
})
|
|
213
252
|
const onAbort = () => {
|
|
214
253
|
aborted = true
|
|
215
254
|
deactivate()
|
|
@@ -250,6 +289,7 @@ function runTurn(opts = {}) {
|
|
|
250
289
|
isResume: !!sessionId, prompt, appendSystemPrompt, codexApp,
|
|
251
290
|
model, providerModel: provider.model, effort, workdir: workdir || process.cwd(), permissionMode })
|
|
252
291
|
pendingTurnStart = true
|
|
292
|
+
turnStartWritten = true
|
|
253
293
|
let turn
|
|
254
294
|
try { turn = await request(turnRequest.method, turnRequest.params) }
|
|
255
295
|
finally { pendingTurnStart = false }
|
|
@@ -328,6 +368,7 @@ function runTurn(opts = {}) {
|
|
|
328
368
|
// 这段回调不在任何 Promise 链里,抛错会直接带走整个进程 ⇒ 核心失败只结束当前轮。
|
|
329
369
|
// 已完成的轮 fatal 会直接返回,结果不受影响。
|
|
330
370
|
const handleLine = (raw) => {
|
|
371
|
+
if (preTurnRejection) return // the turn never started; later output is discarded
|
|
331
372
|
// 对方主动发来的请求同时带 id 和 method:id 是对方的号段,可能与我们的请求相撞,绝不能当响应处理;
|
|
332
373
|
// 不回应它就会一直等,这一轮挂到超时。无人值守,按协议一律拒绝。
|
|
333
374
|
if (raw.id != null && raw.method) {
|
|
@@ -338,7 +379,7 @@ function runTurn(opts = {}) {
|
|
|
338
379
|
}
|
|
339
380
|
if (raw.id != null) {
|
|
340
381
|
const p = requests.get(raw.id)
|
|
341
|
-
if (p) { requests.delete(raw.id); raw.error ? p.fail(Object.assign(new Error(raw.error.message || 'Codex RPC error'), { codexRpcRejected: [-32600, -32601, -32602].includes(raw.error.code) })) : p.ok(raw.result) }
|
|
382
|
+
if (p) { requests.delete(raw.id); raw.error ? p.fail(Object.assign(new Error(raw.error.message || 'Codex RPC error'), { codexRpcError: true, codexRpcRejected: [-32600, -32601, -32602].includes(raw.error.code) })) : p.ok(raw.result) }
|
|
342
383
|
return
|
|
343
384
|
}
|
|
344
385
|
if (onTurnAccepted && pendingTurnStart && !acceptedTurnId) {
|
|
@@ -431,6 +472,7 @@ function runTurn(opts = {}) {
|
|
|
431
472
|
}
|
|
432
473
|
}
|
|
433
474
|
child.stdout.on('data', (chunk) => {
|
|
475
|
+
if (preTurnRejection) return // keep the pipe flowing so the child's close is observed
|
|
434
476
|
if (!onDurableEvent) { processChunk(chunk).catch(fatal); return }
|
|
435
477
|
child.stdout.pause?.()
|
|
436
478
|
streamWork = streamWork.then(() => processChunk(chunk)).catch(failDurableEvent).finally(() => {
|
|
@@ -456,11 +498,15 @@ function runTurn(opts = {}) {
|
|
|
456
498
|
})
|
|
457
499
|
child.on('close', (code) => {
|
|
458
500
|
childCloseObserved = true
|
|
501
|
+
if (preTurnRejection) { witnessPreTurnRejection(); return }
|
|
459
502
|
if (onDurableEvent) streamWork.then(() => durableWork).then(() => { if (!durableFailed) completeClose(code) }, failDurableEvent)
|
|
460
503
|
else completeClose(code)
|
|
461
504
|
})
|
|
462
505
|
start().catch((error) => {
|
|
463
|
-
if (onDurableEvent) {
|
|
506
|
+
if (onDurableEvent) {
|
|
507
|
+
if (canWitnessPreTurnRejection(error)) { rejectBeforeTurn(error); return }
|
|
508
|
+
failDurableEvent(error); return
|
|
509
|
+
}
|
|
464
510
|
if (onTurnAccepted && !acceptedTurnId && !settled) {
|
|
465
511
|
aborted = true
|
|
466
512
|
completed = true
|
|
@@ -324,7 +324,18 @@ function createC2MultiGroupService({ routes, assertWriterLock,
|
|
|
324
324
|
return { ignored: true, reason: `unsupported-${source}` }
|
|
325
325
|
}
|
|
326
326
|
}
|
|
327
|
-
|
|
327
|
+
// A reply or card update awaiting its platform response blocks the durable
|
|
328
|
+
// authority only for that moment. The bot's own card deleted by /stop or
|
|
329
|
+
// /skip comes back as a recall exactly then, and a person may recall a
|
|
330
|
+
// queued message while its queue notice is still on its way. The recall
|
|
331
|
+
// waits for that effect to settle; only an authority closed for good after
|
|
332
|
+
// the recall is a fault. The platform does not await this result, so the
|
|
333
|
+
// wait never rejects.
|
|
334
|
+
const RECALL_WAIT_MS = 60_000
|
|
335
|
+
const RECALL_POLL_MS = 25
|
|
336
|
+
const closedForGood = runtime => (runtime?.closed ?? runtime?.blocked) === true
|
|
337
|
+
const effectInFlight = runtime => runtime?.blocked === true && runtime?.closed === false
|
|
338
|
+
function recall(event, waitedMs = 0) {
|
|
328
339
|
if (suspended.has(event?.chatId)) return { ignored: true, reason: 'group-held' }
|
|
329
340
|
if (maintenance) return { ignored: true, reason: 'upgrading' }
|
|
330
341
|
if (!allowed(event?.chatId)) return { ignored: true }
|
|
@@ -333,8 +344,20 @@ function createC2MultiGroupService({ routes, assertWriterLock,
|
|
|
333
344
|
held()
|
|
334
345
|
binding = bindings.get(event.chatId)
|
|
335
346
|
if (!started || stopPromise || !binding) return { ignored: true, reason: 'ingress-closed' }
|
|
347
|
+
if (effectInFlight(binding.runtime)) {
|
|
348
|
+
if (waitedMs >= RECALL_WAIT_MS) {
|
|
349
|
+
log(`group ${event.chatId}: recall not applied; an outbound effect stayed in flight`)
|
|
350
|
+
return { ignored: true, reason: 'recall-effect-in-flight' }
|
|
351
|
+
}
|
|
352
|
+
return new Promise(resolve => setTimeout(resolve, RECALL_POLL_MS))
|
|
353
|
+
.then(() => recall(event, waitedMs + RECALL_POLL_MS))
|
|
354
|
+
.catch(error => {
|
|
355
|
+
log(`group ${event.chatId}: deferred recall failed: ${reasonOf(error)}`)
|
|
356
|
+
return { ignored: true, reason: 'recall-failed' }
|
|
357
|
+
})
|
|
358
|
+
}
|
|
336
359
|
const handled = binding.engine.handleRecall?.(event) === true
|
|
337
|
-
if (binding.runtime
|
|
360
|
+
if (closedForGood(binding.runtime)) throw new Error('Recall closed its durable authority')
|
|
338
361
|
return handled ? { handled: true } : { ignored: true, reason: 'unsupported-or-inactive-recall' }
|
|
339
362
|
} catch (error) {
|
|
340
363
|
if (!binding || !writerHeld() || !started || stopPromise) return fatal(error)
|
|
@@ -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
|
@@ -1754,7 +1754,8 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
|
|
|
1754
1754
|
if (controller && rt.v2Controller === controller) rt.v2Controller = null
|
|
1755
1755
|
const proofId = `sbot-c2-runner-not-started-v1:${receiptId}:${generation}`
|
|
1756
1756
|
const eventId = `sbot-c2-runner-not-started-commit-v1:${receiptId}:${turnToken}:${generation}`
|
|
1757
|
-
const provenNotStarted = !accepted &&
|
|
1757
|
+
const provenNotStarted = !accepted &&
|
|
1758
|
+
['AGENT_BIN_MISSING', 'AGENT_THREAD_REJECTED'].includes(error?.code) &&
|
|
1758
1759
|
this.durableV2Journal?.lookupRunnerNotStarted(proofId)?.dispatchEffectId === effectId &&
|
|
1759
1760
|
this.durableV2Journal?.hasEvent(eventId) === true
|
|
1760
1761
|
if (provenNotStarted) {
|
|
@@ -1765,6 +1766,33 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
|
|
|
1765
1766
|
authority.close()
|
|
1766
1767
|
throw new Error('C2 v2 unstarted runner receipt remains unsettled')
|
|
1767
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
|
+
}
|
|
1768
1796
|
rt.v2RunnerGeneration = null
|
|
1769
1797
|
rt.v2RunningJobs = null
|
|
1770
1798
|
rt.v2DispatchEffect = null
|