sintbot 1.0.16 → 1.3.0
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 +99 -0
- package/CHANGELOG.json +99 -0
- package/README.md +20 -158
- package/README.zh-CN.md +20 -166
- package/THIRD-PARTY-NOTICES.md +394 -0
- package/bin/sintbot-v2.js +363 -0
- package/docs/AI-ASSISTED-INSTALL.md +11 -35
- package/docs/install.md +61 -167
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +11 -35
- package/docs/zh-CN/install.md +61 -165
- package/install.sh +84 -0
- package/package.json +73 -37
- package/src/agent/claudecode.js +149 -108
- package/src/agent/codexapp.js +458 -0
- package/src/agent/codexcode.js +210 -107
- package/src/agent/errtag.js +34 -0
- package/src/agent/privatecore.js +790 -0
- package/src/agent/privatecore.wasm +0 -0
- package/src/agent/runner.js +10 -8
- package/src/agent/winexec.js +35 -5
- package/src/c2install.js +117 -0
- package/src/config.js +2 -1
- package/src/core/c2cardeffectbridge.js +51 -0
- package/src/core/c2cardterminalpath.js +216 -0
- package/src/core/c2durableingress.js +148 -0
- package/src/core/c2durablejournal.js +1560 -0
- package/src/core/c2durableruntime.js +2361 -0
- package/src/core/c2eventidentity.js +90 -0
- package/src/core/c2globalfault.js +401 -0
- package/src/core/c2localeffectexecutor.js +193 -0
- package/src/core/c2payloadhandles.js +333 -0
- package/src/core/c2persistrelation.js +179 -0
- package/src/core/c2persiststate.js +342 -0
- package/src/core/c2platformrecoverysender.js +76 -0
- package/src/core/c2recoverygate.js +385 -0
- package/src/core/c2runnerinput.js +63 -0
- package/src/core/c2servicestartgate.js +103 -0
- package/src/core/c2sessionbridge.js +72 -0
- package/src/core/c2sourcequarantine.js +30 -0
- package/src/core/c2startuprecovery.js +105 -0
- package/src/core/c2successorbridge.js +151 -0
- package/src/core/c2terminaleffectbridge.js +154 -0
- package/src/core/c2terminalprune.js +108 -0
- package/src/core/c2turnprojection.js +177 -0
- package/src/core/enginecheckpoint.js +211 -0
- package/src/core/engineeffectcursor.js +394 -0
- package/src/core/enginestepcoordinator.js +183 -0
- package/src/core/store.js +52 -8
- package/src/core/v2engine.js +1469 -0
- package/src/platform/feishu.js +324 -67
- package/src/plugins.js +1 -9
- package/assets/design-styles/acid-pop.md +0 -155
- package/assets/design-styles/archive-index.md +0 -161
- package/assets/design-styles/art-deco.md +0 -164
- package/assets/design-styles/cinematic-noir.md +0 -178
- package/assets/design-styles/editorial.md +0 -121
- package/assets/design-styles/extra/README.md +0 -23
- package/assets/design-styles/extra/constructivism.md +0 -146
- package/assets/design-styles/extra/neo-brutalism.md +0 -175
- package/assets/design-styles/extra/new-wave.md +0 -166
- package/assets/design-styles/extra/nordic-material.md +0 -162
- package/assets/design-styles/extra/paris-couture.md +0 -162
- package/assets/design-styles/extra/tropical-modern.md +0 -168
- package/assets/design-styles/extra/ulm-functional.md +0 -182
- package/assets/design-styles/extra/wa-shikimen.md +0 -160
- package/assets/design-styles/extra/y2k-chrome.md +0 -172
- package/assets/design-styles/geometric-pop.md +0 -122
- package/assets/design-styles/hand-drawn.md +0 -117
- package/assets/design-styles/islamic-geometry.md +0 -155
- package/assets/design-styles/liquid-glass.md +0 -183
- package/assets/design-styles/neo-chinese.md +0 -136
- package/assets/design-styles/precision-mono.md +0 -156
- package/assets/design-styles/quiet-luxury.md +0 -163
- package/assets/design-styles/riso-print.md +0 -116
- package/assets/design-styles/soft-friendly.md +0 -174
- package/assets/design-styles/swiss-grid.md +0 -156
- package/assets/design-styles/tech-dark.md +0 -182
- package/assets/favicon.svg +0 -6
- package/assets/logo.svg +0 -24
- package/bin/sbot.js +0 -4716
- package/docs/slack-app-manifest.yaml +0 -104
- package/sbot.config.example.json +0 -235
- package/src/agent/askonce.js +0 -76
- package/src/agent/claudemodels.js +0 -60
- package/src/agent/claudestatus.js +0 -74
- package/src/agent/claudeusage.js +0 -200
- package/src/agent/codexstatus.js +0 -181
- package/src/agent/imagen.js +0 -458
- package/src/agent/quotaerr.js +0 -44
- package/src/agent/transienterr.js +0 -32
- package/src/cdp.js +0 -254
- package/src/cli-secret.js +0 -36
- package/src/clilang.js +0 -31
- package/src/core/cron.js +0 -182
- package/src/core/cronfire.js +0 -120
- package/src/core/cronrun.js +0 -116
- package/src/core/engine.js +0 -2758
- package/src/core/interfaces.js +0 -114
- package/src/core/issues.js +0 -185
- package/src/core/ledger.js +0 -30
- package/src/core/opsalert.js +0 -214
- package/src/core/pending.js +0 -123
- package/src/core/proxy.js +0 -424
- package/src/core/quotaalert.js +0 -79
- package/src/core/quotaerr.js +0 -55
- package/src/core/scheduler.js +0 -59
- package/src/design.js +0 -5161
- package/src/gitrepo.js +0 -150
- package/src/health.js +0 -353
- package/src/installdeps.js +0 -72
- package/src/leftover.js +0 -174
- package/src/platform/boardnotice.js +0 -77
- package/src/platform/boardops.js +0 -36
- package/src/platform/boardwatch.js +0 -447
- package/src/platform/cli.js +0 -45
- package/src/platform/slack.js +0 -1289
- package/src/platform/takeovercard.js +0 -37
- package/src/platform/wecom.js +0 -938
- package/src/platform/wecomcli.js +0 -128
- package/src/platform/wecomgate.js +0 -108
- package/src/platform/wecomname.js +0 -43
- package/src/platform/wecompoll.js +0 -241
- package/src/preview.js +0 -581
- package/src/projectmemory.js +0 -175
- package/src/service.js +0 -317
- package/src/ui/auth.js +0 -31
- package/src/ui/browser.html +0 -392
- package/src/ui/browserroutes.js +0 -234
- package/src/ui/console.html +0 -5526
- package/src/ui/dashboard.js +0 -168
- package/src/ui/feishulogin.js +0 -136
- package/src/ui/server.js +0 -2314
- package/src/ui/sessions.js +0 -78
- package/src/ui/takeover.js +0 -108
- package/src/updater.js +0 -243
- package/src/upgrade.js +0 -35
- package/src/usage.js +0 -144
package/src/agent/codexcode.js
CHANGED
|
@@ -11,9 +11,13 @@
|
|
|
11
11
|
// confirmed. The event mapping mirrors the samples captured there.
|
|
12
12
|
|
|
13
13
|
const { spawn } = require('node:child_process')
|
|
14
|
+
const closedRunnerResults = new WeakSet()
|
|
15
|
+
const isClosedRunnerResult = result => !!result && typeof result === 'object' && closedRunnerResults.has(result)
|
|
16
|
+
const { StringDecoder } = require('node:string_decoder')
|
|
14
17
|
const { IS_WIN, resolveSpawn } = require('./winexec')
|
|
15
|
-
const {
|
|
16
|
-
const {
|
|
18
|
+
const { tagError } = require('./errtag') // 出错原话 → quota / sessionMissing / transient 之一(优先级只在那一处定),与 claude runner 共用
|
|
19
|
+
const { sandboxTier, codexBuildPrompt, codexBuildArgs, codexNormalizeEvent, codexParseError, codexEffortRejection } = require('./privatecore')
|
|
20
|
+
const { coreFailedResult } = require('./errtag')
|
|
17
21
|
|
|
18
22
|
// Warn only when the answer→close (rollout-flush) lag is abnormally high. Real-machine
|
|
19
23
|
// data (Windows, codex 0.142.5) clustered at ~2.0–3.2s, so healthy turns stay silent and
|
|
@@ -30,16 +34,7 @@ const CLOSE_LAG_WARN_MS = 8000
|
|
|
30
34
|
// approval_policy is ALWAYS "never": `codex exec` has no approval IPC, so any other
|
|
31
35
|
// value would block on a TTY prompt that never comes.
|
|
32
36
|
function sandboxMode(permissionMode) {
|
|
33
|
-
return permissionMode
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// Codex item.type → a friendly tool name for sbot's activity/card (mirrors Claude's
|
|
37
|
-
// tool names where sensible). Unknown types pass through under their raw name.
|
|
38
|
-
const CODEX_TOOL_NAMES = {
|
|
39
|
-
command_execution: 'Bash',
|
|
40
|
-
web_search: 'WebSearch',
|
|
41
|
-
file_search: 'FileSearch',
|
|
42
|
-
mcp_tool_call: 'MCP',
|
|
37
|
+
return sandboxTier(permissionMode)
|
|
43
38
|
}
|
|
44
39
|
|
|
45
40
|
// A codex error message is often a raw JSON blob, e.g.
|
|
@@ -48,11 +43,7 @@ const CODEX_TOOL_NAMES = {
|
|
|
48
43
|
// Extract the human message (so a group never sees raw JSON) and the offending param
|
|
49
44
|
// (so the runner can recover from a rejected reasoning effort — see isEffortRejection).
|
|
50
45
|
function parseCodexError(rawMsg) {
|
|
51
|
-
|
|
52
|
-
if (s.startsWith('{')) {
|
|
53
|
-
try { const j = JSON.parse(s); if (j && j.error) return { text: String(j.error.message || s), param: String(j.error.param || '') } } catch { /* not JSON — fall through */ }
|
|
54
|
-
}
|
|
55
|
-
return { text: s, param: '' }
|
|
46
|
+
return codexParseError(rawMsg)
|
|
56
47
|
}
|
|
57
48
|
|
|
58
49
|
// Map one raw `codex exec --json` event to a normalized AgentEvent (interfaces.js),
|
|
@@ -60,68 +51,27 @@ function parseCodexError(rawMsg) {
|
|
|
60
51
|
// the final text — Codex streams the answer as `agent_message` items, so the caller
|
|
61
52
|
// fills result.text from the last one.
|
|
62
53
|
function normalizeEvent(ev) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
case 'reasoning':
|
|
75
|
-
return done ? { type: 'assistant', thinking: item.text || '' } : null
|
|
76
|
-
case 'command_execution': // shell — surface as a Bash tool_use, then its result
|
|
77
|
-
return done
|
|
78
|
-
? { type: 'tool_result', results: [{ type: 'tool_result', content: item.aggregated_output || '', is_error: item.exit_code != null && item.exit_code !== 0 }] }
|
|
79
|
-
: { type: 'assistant', tools: [{ name: 'Bash', input: { command: item.command } }] }
|
|
80
|
-
case 'file_change': // one or more path edits — surface each as a Write/Delete
|
|
81
|
-
return done ? null : { type: 'assistant', tools: (item.changes || []).map((c) => ({ name: c.kind === 'delete' ? 'Delete' : 'Write', input: { path: c.path } })) }
|
|
82
|
-
default: {
|
|
83
|
-
const name = CODEX_TOOL_NAMES[item.type] || item.type
|
|
84
|
-
return done ? null : { type: 'assistant', tools: [{ name, input: item }] }
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
case 'turn.completed':
|
|
90
|
-
// ⚠ codex 的 usage 是**会话累计值**不是本轮增量(2026-08-09 对照 rollout 的
|
|
91
|
-
// total_token_usage 坐实;resume 的轮把历史轮全算在内)。标 usageCumulative,
|
|
92
|
-
// engine 侧经 store 基线换算成本轮增量 —— 直接当单轮记会把账越记越大。
|
|
93
|
-
return { type: 'result', subtype: 'success', usage: ev.usage, usageCumulative: true } // text filled by caller
|
|
94
|
-
|
|
95
|
-
case 'turn.failed': {
|
|
96
|
-
const { text, param } = parseCodexError(ev.error && ev.error.message)
|
|
97
|
-
const r = { type: 'result', subtype: 'error', isError: true, text: text || 'codex turn failed' }
|
|
98
|
-
if (param === 'reasoning.effort') r.effortRejected = true
|
|
99
|
-
// 瞬时错(服务端过载 / 限流)给群面一句人话;text 仍是原话,留给账本(判据在 transienterr,两 runner 共用)
|
|
100
|
-
const t = classifyTransient(text); if (t) r.transient = t
|
|
101
|
-
const q = classifyQuota(text); if (q) r.quota = q // 额度 / 账号类:引擎据此切备用模型
|
|
102
|
-
return r
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
case 'error': {
|
|
106
|
-
const { text, param } = parseCodexError(ev.message)
|
|
107
|
-
const r = { type: 'result', subtype: 'error', isError: true, text: text || 'codex error', raw: ev }
|
|
108
|
-
if (param === 'reasoning.effort') r.effortRejected = true
|
|
109
|
-
const t = classifyTransient(text); if (t) r.transient = t
|
|
110
|
-
const q = classifyQuota(text); if (q) r.quota = q
|
|
111
|
-
return r
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
default:
|
|
115
|
-
return null // turn.started and other bookkeeping — ignore
|
|
54
|
+
const result = codexNormalizeEvent(ev)
|
|
55
|
+
if (result === null) return null
|
|
56
|
+
// JSON cannot carry undefined. Preserve the frozen runner's public shape.
|
|
57
|
+
if (ev.type === 'thread.started') result.sessionId = ev.thread_id
|
|
58
|
+
else if (ev.type === 'turn.completed') result.usage = ev.usage
|
|
59
|
+
else if (ev.type === 'error') result.raw = ev
|
|
60
|
+
else if (ev.type === 'item.started' && result.type === 'assistant' && result.tools) {
|
|
61
|
+
const item = ev.item || {}
|
|
62
|
+
if (item.type === 'command_execution') result.tools[0].input.command = item.command
|
|
63
|
+
else if (item.type === 'file_change') (item.changes || []).forEach((change, i) => { result.tools[i].input.path = change.path })
|
|
64
|
+
else if (!item.type) result.tools[0].name = item.type
|
|
116
65
|
}
|
|
66
|
+
if (result.type === 'result' && result.isError) tagError(result, parseCodexError(ev.type === 'error' ? ev.message : ev.error && ev.error.message).text)
|
|
67
|
+
return result
|
|
117
68
|
}
|
|
118
69
|
|
|
119
70
|
// Prepend sbot's system prompt / conventions into the user prompt — FIRST TURN ONLY.
|
|
120
71
|
// `codex exec` has no --append-system-prompt; the prompt text is the only channel.
|
|
121
72
|
// On resume the convention persists in-session, so it isn't re-sent.
|
|
122
73
|
function buildPrompt(prompt, appendSystemPrompt, isResume) {
|
|
123
|
-
|
|
124
|
-
return `Before answering, follow these project-level instructions for this session. They are not user content.\n\n${appendSystemPrompt}\n\n---\n\nUser message:\n${prompt}`
|
|
74
|
+
return codexBuildPrompt(prompt, appendSystemPrompt, isResume)
|
|
125
75
|
}
|
|
126
76
|
|
|
127
77
|
// Build the argv for `codex exec`. Fresh and resume differ in verified ways (Windows
|
|
@@ -129,24 +79,7 @@ function buildPrompt(prompt, appendSystemPrompt, isResume) {
|
|
|
129
79
|
// and `--cd` (relies on process cwd); fresh accepts `--sandbox` + `--cd`. Prompt rides
|
|
130
80
|
// on stdin (trailing `-`), dodging Windows argv mangling of arbitrary chat text.
|
|
131
81
|
function buildArgs({ sessionId, model, workdir, permissionMode, reasoningEffort, autocompactTokens }) {
|
|
132
|
-
|
|
133
|
-
const args = ['exec']
|
|
134
|
-
if (sessionId) args.push('resume', sessionId)
|
|
135
|
-
args.push('--skip-git-repo-check')
|
|
136
|
-
if (sessionId) args.push('-c', `sandbox_mode=${tier}`)
|
|
137
|
-
else args.push('--sandbox', tier)
|
|
138
|
-
args.push('-c', 'approval_policy=never')
|
|
139
|
-
// Per-provider reasoning-effort tuning (minimal/low/medium/high). A `-c` override,
|
|
140
|
-
// valid on both fresh and resume; omitted ⇒ codex uses the model's default.
|
|
141
|
-
if (reasoningEffort) args.push('-c', `model_reasoning_effort=${reasoningEffort}`)
|
|
142
|
-
// 长会话上下文上限:超过即让 codex 原地压缩(键名 0.144 二进制核对)。不传 ⇒ codex 只在
|
|
143
|
-
// 模型窗口边缘才压,多天群会话的历史一直全量驮着。`-c` 逐次覆盖,不动机器全局 config.toml。
|
|
144
|
-
if (autocompactTokens > 0) args.push('-c', `model_auto_compact_token_limit=${autocompactTokens}`)
|
|
145
|
-
if (model) args.push('--model', model)
|
|
146
|
-
args.push('--json')
|
|
147
|
-
if (!sessionId && workdir) args.push('--cd', workdir)
|
|
148
|
-
args.push('-') // prompt on stdin
|
|
149
|
-
return args
|
|
82
|
+
return codexBuildArgs({ sessionId, model, workdir, permissionMode, reasoningEffort, autocompactTokens })
|
|
150
83
|
}
|
|
151
84
|
|
|
152
85
|
// A codex turn.failed / error result whose offending param is the reasoning effort —
|
|
@@ -154,7 +87,7 @@ function buildArgs({ sessionId, model, workdir, permissionMode, reasoningEffort,
|
|
|
154
87
|
// (model/list advertises a model's full effort catalog, but the runtime API narrows it by
|
|
155
88
|
// plan: e.g. a Plus account's gpt-5.6-terra rejects max/ultra though model/list lists them.)
|
|
156
89
|
function isEffortRejection(result) {
|
|
157
|
-
return
|
|
90
|
+
return codexEffortRejection(result)
|
|
158
91
|
}
|
|
159
92
|
|
|
160
93
|
/**
|
|
@@ -175,13 +108,32 @@ function runTurn(opts = {}) {
|
|
|
175
108
|
// the first result event, idempotently — an early error would swallow the retry's
|
|
176
109
|
// success). SUCCESS results pass through immediately, preserving prompt finalize.
|
|
177
110
|
let heldErr = null
|
|
111
|
+
let retrySafe = true
|
|
112
|
+
// A rejected request is safe to resubmit only before Codex emits any item or
|
|
113
|
+
// successful turn event. Inspect raw events because some completed items do not
|
|
114
|
+
// produce a normalized event (for example, file_change).
|
|
115
|
+
const observeFirstAttempt = (raw) => {
|
|
116
|
+
// Once the durable runner-start receipt is acknowledged, even a later
|
|
117
|
+
// request-validation error cannot launch a second process under that receipt.
|
|
118
|
+
if ((opts.onTurnAccepted || opts.onDurableEvent) && raw.type === 'turn.started') retrySafe = false
|
|
119
|
+
if (!['thread.started', 'turn.started', 'turn.failed', 'error'].includes(raw.type)) retrySafe = false
|
|
120
|
+
}
|
|
178
121
|
const proxy = opts.onEvent
|
|
179
122
|
? (ev) => { if (ev && ev.type === 'result' && ev.isError) { heldErr = ev; return } opts.onEvent(ev) }
|
|
180
123
|
: undefined
|
|
181
|
-
return runOnce({ ...opts, onEvent: proxy }, wantEffort).then((res) => {
|
|
182
|
-
if (heldErr && isEffortRejection(res.result) && !res.aborted) {
|
|
124
|
+
return runOnce({ ...opts, onEvent: proxy, onRawEvent: observeFirstAttempt }, wantEffort).then((res) => {
|
|
125
|
+
if (retrySafe && heldErr && isEffortRejection(res.result) && !res.aborted) {
|
|
183
126
|
if (opts.log) opts.log(`codex 推理强度 '${wantEffort}' 不被当前模型/套餐接受(model/list 报模型上限,运行时按套餐收窄)— 已回退模型默认重试一轮`)
|
|
184
|
-
|
|
127
|
+
// 引擎在 result 事件到达时就定稿卡片(不等进程关闭);回退身份必须随事件走,
|
|
128
|
+
// 只在 Promise 返回值上加 effortFellBack 会让页脚误报刚被拒绝的强度。
|
|
129
|
+
const retryOpts = opts.onEvent
|
|
130
|
+
? { ...opts, onEvent: (ev) => opts.onEvent(ev && ev.type === 'result' ? { ...ev, effortFellBack: wantEffort } : ev) }
|
|
131
|
+
: opts
|
|
132
|
+
return runOnce(retryOpts, '').then((r2) => {
|
|
133
|
+
const retried = { ...r2, result: r2.result ? { ...r2.result, effortFellBack: wantEffort } : r2.result, effortFellBack: wantEffort }
|
|
134
|
+
if (isClosedRunnerResult(r2)) closedRunnerResults.add(retried)
|
|
135
|
+
return retried
|
|
136
|
+
})
|
|
185
137
|
}
|
|
186
138
|
// Not recoverable: the resolved result carries the (readable) error; the engine's
|
|
187
139
|
// post-await finalizeTurn fallback reports it (attempt 1 never emitted it via onEvent).
|
|
@@ -207,6 +159,10 @@ function runOnce(opts, effort) {
|
|
|
207
159
|
appendSystemPrompt,
|
|
208
160
|
extraEnv,
|
|
209
161
|
onEvent,
|
|
162
|
+
onDurableEvent,
|
|
163
|
+
onRawEvent,
|
|
164
|
+
onTurnAccepted,
|
|
165
|
+
onSpawnFailed,
|
|
210
166
|
signal,
|
|
211
167
|
softStopGraceMs = 10000,
|
|
212
168
|
log = () => {}, // daemon logger — warns when the answer→close (rollout-flush) lag is high
|
|
@@ -215,6 +171,9 @@ function runOnce(opts, effort) {
|
|
|
215
171
|
} = opts || {}
|
|
216
172
|
return new Promise((resolve, reject) => {
|
|
217
173
|
if (!prompt) return reject(new Error('runTurn: prompt required'))
|
|
174
|
+
if (onTurnAccepted !== undefined && typeof onTurnAccepted !== 'function') return reject(new TypeError('onTurnAccepted must be a function'))
|
|
175
|
+
if (onDurableEvent !== undefined && typeof onDurableEvent !== 'function') return reject(new TypeError('onDurableEvent must be a function'))
|
|
176
|
+
if (onSpawnFailed !== undefined && typeof onSpawnFailed !== 'function') return reject(new TypeError('onSpawnFailed must be a function'))
|
|
218
177
|
|
|
219
178
|
const env = { ...process.env }
|
|
220
179
|
delete env.CLAUDECODE // don't let a nested Claude Code session confuse the child
|
|
@@ -228,7 +187,8 @@ function runOnce(opts, effort) {
|
|
|
228
187
|
if (extraEnv) Object.assign(env, extraEnv)
|
|
229
188
|
|
|
230
189
|
const args = buildArgs({ sessionId, model: model || provider.model, workdir, permissionMode, reasoningEffort: effort, autocompactTokens })
|
|
231
|
-
const { file, spawnArgs } = resolveSpawn(bin, args)
|
|
190
|
+
const { file, spawnArgs, fallback } = resolveSpawn(bin, args)
|
|
191
|
+
if (fallback) log(`配置的 agent 路径 '${bin}' 不存在,本轮改用 ${fallback} —— 请把配置改回正确路径`) // 与 claude runner 同一条兜底
|
|
232
192
|
const child = spawnFn(file, spawnArgs, {
|
|
233
193
|
env,
|
|
234
194
|
cwd: workdir || process.cwd(),
|
|
@@ -251,6 +211,12 @@ function runOnce(opts, effort) {
|
|
|
251
211
|
// safe: force-releasing before the rollout is durable breaks the next resume. Logged on
|
|
252
212
|
// close so a real-machine run surfaces it; not fired on abort (rollout is forfeited then).
|
|
253
213
|
let answerAtMs = 0
|
|
214
|
+
const durableStart = typeof onTurnAccepted === 'function'
|
|
215
|
+
let acceptedStart = false
|
|
216
|
+
let acceptanceFailed = false
|
|
217
|
+
const heldEvents = []
|
|
218
|
+
let durableWork = Promise.resolve()
|
|
219
|
+
let durableFailed = false
|
|
254
220
|
|
|
255
221
|
// /stop and the wall-clock cap abort via this signal. On Windows the codex.exe under
|
|
256
222
|
// the cmd.exe shim can SURVIVE a tree-kill and keep the inherited stdout pipe open, so
|
|
@@ -260,8 +226,14 @@ function runOnce(opts, effort) {
|
|
|
260
226
|
// (sbot's never-hang invariant) even if a grandchild clings to the pipe.
|
|
261
227
|
let aborted = false
|
|
262
228
|
let settled = false
|
|
229
|
+
let childCloseObserved = false
|
|
230
|
+
let childOutputObserved = false
|
|
263
231
|
let graceTimer = null
|
|
264
232
|
const done = (fn) => { if (settled) return; settled = true; if (graceTimer) clearTimeout(graceTimer); if (signal) signal.removeEventListener('abort', onAbort); fn() }
|
|
233
|
+
const resolveRun = result => {
|
|
234
|
+
if (childCloseObserved) closedRunnerResults.add(result)
|
|
235
|
+
resolve(result)
|
|
236
|
+
}
|
|
265
237
|
const killChild = () => {
|
|
266
238
|
if (IS_WIN && child.pid) {
|
|
267
239
|
try { spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true }) } catch {}
|
|
@@ -269,7 +241,8 @@ function runOnce(opts, effort) {
|
|
|
269
241
|
return
|
|
270
242
|
}
|
|
271
243
|
try { child.kill('SIGTERM') } catch { /* already gone */ }
|
|
272
|
-
setTimeout(() => { try { child.kill('SIGKILL') } catch {} }, softStopGraceMs)
|
|
244
|
+
const reapTimer = setTimeout(() => { try { child.kill('SIGKILL') } catch {} }, softStopGraceMs)
|
|
245
|
+
reapTimer.unref?.()
|
|
273
246
|
}
|
|
274
247
|
// Resolve on the process 'close' — NOT on `turn.completed`. Codex finalizes its session
|
|
275
248
|
// rollout on CLEAN EXIT (writes then commits the file), so hard-killing a still-running
|
|
@@ -284,15 +257,66 @@ function runOnce(opts, effort) {
|
|
|
284
257
|
const onAbort = () => {
|
|
285
258
|
aborted = true
|
|
286
259
|
killChild()
|
|
287
|
-
graceTimer = setTimeout(() =>
|
|
260
|
+
graceTimer = setTimeout(() => {
|
|
261
|
+
const complete = () => done(() => resolveRun({ sessionId: capturedSessionId, result: lastResult, code: null, aborted: true }))
|
|
262
|
+
if (onDurableEvent) durableWork.then(complete, failDurableEvent)
|
|
263
|
+
else complete()
|
|
264
|
+
}, softStopGraceMs)
|
|
288
265
|
}
|
|
289
266
|
if (signal) {
|
|
290
267
|
if (signal.aborted) onAbort()
|
|
291
268
|
else signal.addEventListener('abort', onAbort, { once: true })
|
|
292
269
|
}
|
|
293
270
|
|
|
294
|
-
|
|
295
|
-
|
|
271
|
+
// 这段回调不在任何 Promise 链里,抛错会直接带走整个进程 ⇒ 核心失败只结束当前轮。
|
|
272
|
+
// 已经拿到结果时不杀进程:codex 要干净退出才会提交可续接的会话文件。
|
|
273
|
+
// 强杀后这个新 thread id 没有可续接的文件,所以交回传入的旧 id(语义同下面的崩溃退出)。
|
|
274
|
+
let coreFailed = false
|
|
275
|
+
const failCore = (error) => {
|
|
276
|
+
if (onDurableEvent) { failDurableEvent(error); return }
|
|
277
|
+
if (durableStart && !acceptedStart) { failTurnAcceptance(error); return }
|
|
278
|
+
coreFailed = true
|
|
279
|
+
buf = ''
|
|
280
|
+
log(`binary core failed in codex stream: ${error && error.message}`)
|
|
281
|
+
if (lastResult) return
|
|
282
|
+
capturedSessionId = sessionId || null
|
|
283
|
+
lastResult = coreFailedResult(capturedSessionId)
|
|
284
|
+
if (onEvent) { try { onEvent(lastResult) } catch { /* renderer must not kill the turn */ } }
|
|
285
|
+
killChild()
|
|
286
|
+
}
|
|
287
|
+
const failTurnAcceptance = (error) => {
|
|
288
|
+
if (acceptanceFailed || settled) return
|
|
289
|
+
acceptanceFailed = true
|
|
290
|
+
buf = ''
|
|
291
|
+
heldEvents.length = 0
|
|
292
|
+
killChild()
|
|
293
|
+
done(() => reject(error))
|
|
294
|
+
}
|
|
295
|
+
const failDurableEvent = (error) => {
|
|
296
|
+
if (durableFailed || settled) return
|
|
297
|
+
durableFailed = true
|
|
298
|
+
buf = ''
|
|
299
|
+
heldEvents.length = 0
|
|
300
|
+
killChild()
|
|
301
|
+
done(() => reject(error))
|
|
302
|
+
}
|
|
303
|
+
const emitEvent = (ev) => { if (onEvent) { try { onEvent(ev) } catch { /* renderer must not kill the turn */ } } }
|
|
304
|
+
const deliverEvent = async (ev) => {
|
|
305
|
+
if (onDurableEvent) {
|
|
306
|
+
let timer
|
|
307
|
+
try {
|
|
308
|
+
await Promise.race([
|
|
309
|
+
Promise.resolve().then(() => onDurableEvent(ev)),
|
|
310
|
+
new Promise((_, fail) => { timer = setTimeout(() => fail(new Error('Codex durable event hook timed out')), 10000) }),
|
|
311
|
+
])
|
|
312
|
+
} finally { clearTimeout(timer) }
|
|
313
|
+
}
|
|
314
|
+
if (!durableFailed && !settled) emitEvent(ev)
|
|
315
|
+
}
|
|
316
|
+
const stdoutDecoder = new StringDecoder('utf8') // 一个汉字可能被拆在两个分块里,逐块 toString 会变成乱码
|
|
317
|
+
const processChunk = async (chunk) => {
|
|
318
|
+
if (coreFailed || acceptanceFailed || durableFailed || ((durableStart || onDurableEvent) && (aborted || settled))) return
|
|
319
|
+
buf += stdoutDecoder.write(chunk) // Node reads bytes → UTF-8, no PowerShell GBK pipe issue
|
|
296
320
|
let nl
|
|
297
321
|
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
298
322
|
const line = buf.slice(0, nl).trim()
|
|
@@ -300,23 +324,87 @@ function runOnce(opts, effort) {
|
|
|
300
324
|
if (!line) continue
|
|
301
325
|
let raw
|
|
302
326
|
try { raw = JSON.parse(line) } catch { continue } // skip non-JSON noise
|
|
327
|
+
if (onRawEvent) { try { onRawEvent(raw) } catch { /* observer must not kill the turn */ } }
|
|
303
328
|
if (raw.type === 'thread.started' && raw.thread_id) capturedSessionId = raw.thread_id
|
|
304
|
-
|
|
329
|
+
if (durableStart && !acceptedStart) {
|
|
330
|
+
if (raw.type === 'turn.started') {
|
|
331
|
+
try {
|
|
332
|
+
const answer = onTurnAccepted({
|
|
333
|
+
channel: 'codex-exec',
|
|
334
|
+
sessionId: capturedSessionId,
|
|
335
|
+
turnId: typeof raw.turn_id === 'string' ? raw.turn_id : typeof raw.turn?.id === 'string' ? raw.turn.id : null,
|
|
336
|
+
})
|
|
337
|
+
if (answer && typeof answer.then === 'function') throw new TypeError('onTurnAccepted must complete synchronously')
|
|
338
|
+
acceptedStart = true
|
|
339
|
+
for (const held of heldEvents) {
|
|
340
|
+
if (onDurableEvent) {
|
|
341
|
+
const event = normalizeEvent(held.raw)
|
|
342
|
+
if (event) await deliverEvent(event)
|
|
343
|
+
} else emitEvent(held)
|
|
344
|
+
}
|
|
345
|
+
heldEvents.length = 0
|
|
346
|
+
} catch (error) { failTurnAcceptance(error); return }
|
|
347
|
+
} else if (!['thread.started', 'turn.failed', 'error'].includes(raw.type)) {
|
|
348
|
+
failTurnAcceptance(new Error('Codex emitted turn effects before turn.started'))
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
// The pre-start thread identity is control data. Delay its event
|
|
353
|
+
// normalization until the synchronous acceptance proof succeeds.
|
|
354
|
+
if (onDurableEvent && durableStart && !acceptedStart) {
|
|
355
|
+
heldEvents.push({ raw })
|
|
356
|
+
continue
|
|
357
|
+
}
|
|
358
|
+
let ev
|
|
359
|
+
try { ev = normalizeEvent(raw) } catch (error) { failCore(error); return }
|
|
305
360
|
if (!ev) continue
|
|
306
361
|
if (ev.type === 'assistant' && ev.text) lastAgentText = ev.text // last agent_message = final answer
|
|
307
362
|
if (ev.type === 'result') { if (ev.subtype !== 'error' && !ev.text) ev.text = lastAgentText; lastResult = ev; if (!answerAtMs) answerAtMs = now() }
|
|
308
|
-
if (
|
|
363
|
+
if (durableStart && !acceptedStart) heldEvents.push(ev)
|
|
364
|
+
else if (onDurableEvent) await deliverEvent(ev)
|
|
365
|
+
else emitEvent(ev)
|
|
309
366
|
}
|
|
367
|
+
}
|
|
368
|
+
child.stdout.on('data', (chunk) => {
|
|
369
|
+
childOutputObserved = true
|
|
370
|
+
if (!onDurableEvent) { processChunk(chunk).catch(failDurableEvent); return }
|
|
371
|
+
child.stdout.pause?.()
|
|
372
|
+
durableWork = durableWork.then(() => processChunk(chunk)).catch(failDurableEvent).finally(() => {
|
|
373
|
+
if (!durableFailed && !settled) child.stdout.resume?.()
|
|
374
|
+
})
|
|
310
375
|
})
|
|
311
376
|
|
|
312
|
-
child.stderr.on('data', (c) => { stderr += c.toString('utf8') })
|
|
377
|
+
child.stderr.on('data', (c) => { childOutputObserved = true; stderr += c.toString('utf8') })
|
|
313
378
|
|
|
314
379
|
child.on('error', (err) => done(() => {
|
|
315
|
-
|
|
380
|
+
// 人话进群、原话进日志、code 给引擎发告警 —— 与 claude runner 同口径
|
|
381
|
+
if (err.code === 'ENOENT') {
|
|
382
|
+
// Node emits this only when spawning the child failed. The callback
|
|
383
|
+
// is a narrow host witness, not a model error classifier: a PID,
|
|
384
|
+
// accepted turn, output, abort, or earlier settlement disqualifies it.
|
|
385
|
+
if (onSpawnFailed && child.pid == null && !acceptedStart &&
|
|
386
|
+
!childOutputObserved && !aborted && !acceptanceFailed && !durableFailed) {
|
|
387
|
+
try {
|
|
388
|
+
const acknowledged = onSpawnFailed({ backend: 'codex-exec',
|
|
389
|
+
code: 'ENOENT', pid: null })
|
|
390
|
+
if (acknowledged !== true) throw new Error('Codex spawn-failure witness was not confirmed synchronously')
|
|
391
|
+
} catch (witnessError) { reject(witnessError); return }
|
|
392
|
+
}
|
|
393
|
+
log(`cannot find agent binary '${bin}' (and no fallback copy) — is Codex CLI installed? (npm i -g @openai/codex)`)
|
|
394
|
+
return reject(Object.assign(new Error('模型程序在这台机器上找不到了,需要管理员处理'), { code: 'AGENT_BIN_MISSING' }))
|
|
395
|
+
}
|
|
316
396
|
reject(err)
|
|
317
397
|
}))
|
|
318
398
|
|
|
319
|
-
|
|
399
|
+
const completeClose = (code) => {
|
|
400
|
+
if (onDurableEvent && code !== 0 && !lastResult && !aborted) {
|
|
401
|
+
done(() => reject(new Error(`codex exited ${code} before a durable result`)))
|
|
402
|
+
return
|
|
403
|
+
}
|
|
404
|
+
if (durableStart && !acceptedStart && !aborted && !acceptanceFailed) {
|
|
405
|
+
done(() => reject(new Error('Codex exited before turn.started acceptance')))
|
|
406
|
+
return
|
|
407
|
+
}
|
|
320
408
|
// Watch the answer→close (rollout-flush) lag. Healthy = ~2–3s (real-machine verified),
|
|
321
409
|
// so stay silent then and only warn on a regression — early notice before the engine's
|
|
322
410
|
// 20s hardReleaseMs backstop would force-release and forfeit the rollout. Not on abort
|
|
@@ -326,11 +414,26 @@ function runOnce(opts, effort) {
|
|
|
326
414
|
if (lagMs > CLOSE_LAG_WARN_MS) log(`codex 收尾偏慢: 答案→close ${lagMs}ms(rollout 落盘, code=${code})— 正常约 2–3s;若持续接近 20s 兜底请调大 agent.hardReleaseMs`)
|
|
327
415
|
}
|
|
328
416
|
done(() => {
|
|
417
|
+
// 只有 stderr、没有任何 result 事件就非零退出(真实案例:登录令牌被吊销 ⇒ 刷模型清单 401 后直接退出)。
|
|
418
|
+
// 此前这里 reject ⇒ 引擎走「执行失败」裸抛路:英文原话 + 网址原样进群、不过额度 / 账号判据、零告警。
|
|
419
|
+
// 现在合成一条 error result,与 turn.failed 同一条路:瞬时 / 账号判据 → 人话 / 切备用 / 告警;原话仍留给账本与日志。
|
|
420
|
+
// 退出码放文案头,排障时一眼分得清是进程崩了还是服务端拒了。引擎零改动。
|
|
329
421
|
if (code !== 0 && !lastResult && !aborted) {
|
|
330
|
-
|
|
422
|
+
// 取尾不取头:致命的那行通常在最后,前面可能是一大段 WARN;截了头判据会漏、原话进群零告警(审查提醒)
|
|
423
|
+
try { lastResult = normalizeEvent({ type: 'error', message: `codex exited ${code}: ${stderr.trim().slice(-800) || '(no stderr)'}` }) }
|
|
424
|
+
catch (error) { log(`binary core failed on codex exit: ${error && error.message}`); lastResult = coreFailedResult(sessionId || null) }
|
|
425
|
+
// 崩着退出时 codex 多半还没提交可续接的会话文件 ⇒ 不把本轮刚拿到的新 thread id 交回去:引擎会照常落盘,
|
|
426
|
+
// 下一轮 resume 它就报「no rollout found」,而引擎对这种失败没有自愈路(只有空轮 / 被掐断的会话会换新会话)。
|
|
427
|
+
// 续接的轮仍交回传入的旧 id(它的文件本来就在)。与此前 reject 路「不落盘」同一语义。
|
|
428
|
+
capturedSessionId = sessionId || null
|
|
331
429
|
}
|
|
332
|
-
|
|
430
|
+
resolveRun({ sessionId: capturedSessionId, result: lastResult, code, aborted })
|
|
333
431
|
})
|
|
432
|
+
}
|
|
433
|
+
child.on('close', (code) => {
|
|
434
|
+
childCloseObserved = true
|
|
435
|
+
if (onDurableEvent) durableWork.then(() => { if (!durableFailed) completeClose(code) })
|
|
436
|
+
else completeClose(code)
|
|
334
437
|
})
|
|
335
438
|
})
|
|
336
439
|
}
|
|
@@ -347,4 +450,4 @@ function probeVersion(bin = 'codex') {
|
|
|
347
450
|
})
|
|
348
451
|
}
|
|
349
452
|
|
|
350
|
-
module.exports = { runTurn, runOnce, probeVersion, normalizeEvent, parseCodexError, isEffortRejection, sandboxMode, buildArgs, buildPrompt }
|
|
453
|
+
module.exports = { runTurn, runOnce, isClosedRunnerResult, probeVersion, normalizeEvent, parseCodexError, isEffortRejection, sandboxMode, buildArgs, buildPrompt }
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// 出错原话 → 结果上的结构化标记:两个 runner 与 App Server 共用的**唯一入口**。
|
|
2
|
+
//
|
|
3
|
+
// 三类判据各住各的文件(签名表是 runner 层的知识),但**谁优先只在这里定一次**,第一个命中即止:
|
|
4
|
+
// ① 额度 / 账号(quota)—— 要人处理或等重置,切备用模型、发告警;
|
|
5
|
+
// ② 续接的会话已不存在(sessionMissing)—— 清死 id、新会话重做;
|
|
6
|
+
// ③ 瞬时错(transient + transientKind:过载 / 限流 / 网关 / 网络)—— 群里说「稍后接着做」,网络档计连续失败。
|
|
7
|
+
// 之前三处各自往结果上挂字段,同一句原话可能两样都挂上(如「503 … your subscription has expired」):群里说
|
|
8
|
+
// 「稍后再试」、网络计数加一,同时又切模型、发账号告警 —— 互相矛盾。排他次序必须只有一份,所以收在这里。
|
|
9
|
+
// ⚠ 只挂标记,不改 r.text:账本的失败原因要留服务端原话。
|
|
10
|
+
const { classifyError } = require('./privatecore')
|
|
11
|
+
|
|
12
|
+
/** 按优先级给出错结果挂上 quota / sessionMissing / transient(+transientKind) 之一;返回 r。 */
|
|
13
|
+
function tagError(r, text) {
|
|
14
|
+
const decision = classifyError(text)
|
|
15
|
+
if (decision?.quota) { r.quota = decision.quota; return r }
|
|
16
|
+
if (decision?.sessionMissing) { r.sessionMissing = true; return r }
|
|
17
|
+
const t = decision?.transient
|
|
18
|
+
if (t) { r.transient = t.human; r.transientKind = t.kind }
|
|
19
|
+
return r
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// 二进制核心失败的统一收尾:这一轮按出错结束、进程不受影响。文案进群,原因只进日志。
|
|
23
|
+
const CORE_FAILED_TEXT = '这一轮内部处理失败,已结束,请重新发送。'
|
|
24
|
+
|
|
25
|
+
function coreFailedResult(sessionId) {
|
|
26
|
+
return { type: 'result', subtype: 'core_failed', text: CORE_FAILED_TEXT, isError: true, coreFailed: true, sessionId }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isCoreFailure(error) {
|
|
30
|
+
return error instanceof RangeError || error instanceof WebAssembly.RuntimeError ||
|
|
31
|
+
String(error && error.message).startsWith('SintBot binary core')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { tagError, CORE_FAILED_TEXT, coreFailedResult, isCoreFailure }
|