Repository navigation
[Bug] Host enters a CPU-bound loop and blocks the event loop when a turn starts in a large session (~700k+ tokens) #7115
Replies: 2 comments
|
We have been reading the same window on published tarballs, and we can add one narrowing, one correction to what your The window you localized is one callIn
Your log records Auto-compaction runs inside that waterfall
One detail that may matter for reading the log: exceptions raised in that hook are caught and only warned — — so a compaction path that throws does not fail the turn, and leaves a warning rather than a stop. What "no
|
| Version | thresholdTokens |
|---|---|
0.1.5-rc.2 / 0.1.5-rc.3 / 0.1.6-alpha.1 / 0.1.6-alpha.2 |
Math.floor(contextWindow * policy.thresholdRatio) (at :111) |
0.1.7-alpha.1 / 0.1.7-alpha.2 / 0.1.7-rc.1 / 0.1.7-rc.2 |
Math.floor(Math.min(contextWindow * policy.thresholdRatio, pressureBudgetTokens)) (at :132), with resolveCompactSpec(policy, contextWindow, reservedCompletionTokens) (declared :124) |
So the first published release that contains the clamp is 0.1.7-alpha.1. On reaching it, the position is mixed: as of 2026-09-24 the package's dist-tags are latest = 0.0.1-rc.3 (no clamp), next = 0.1.7-rc.2 (clamp present) and alpha = 0.1.7-alpha.2 (clamp present) — so the next line carries the fixed version, while a default install still resolves to one that does not. We cite the tags with a date because that line moved twice within two days (0.1.5-rc.3 → 0.1.7-rc.1 → 0.1.7-rc.2); the version that first contains the clamp does not move.
What would separate the remaining half
If the wedge still reproduces when auto-compaction cannot trigger — compaction plugin not present, or a configuration that keeps pressure far below the threshold — then the pre-step waterfall is not where it lives, and the assembly path at :890/:892–:893 is. A single run splits those two.
One secondary candidate if it does not: dsh-compaction/lib/index.js:47–:58 rebuilds its tool-pairing balance in full whenever surface.replaceGeneration changes, so if your wedge correlates with a preceding surface replacement, that is a separate thing to check.
Authorship note: reproduced and measured by me against published packages; root-cause tracing and drafting assisted by AI; verification and publication by me. I have no engineering background — if any technical claim reads wrong, please call it out; I will re-verify against the toolchain and correct.
Reported by the OfferKuai Team — Founder: Zhaofeng (Yaming). Website: https://www.offerkuai.com/ | Contact: contact@offerkuai.com
中文版
我们也在看同一段代码,读的是已发布的包产物。这里能补三件:把你定位的那个窗口收窄到具体几行、说明"没有 request/header"能排除什么又不能排除什么、以及你报告的第二半是在哪个版本被修掉的。
你定位的那个窗口,其实只有一次调用
在 0.1.5-rc.2 里,turn/start 与 step/start 之间只隔一次调用:await this.preStep(target, { turn, step })(dsh-agent-loop/lib/index.js:937,函数体在 :885)。它按顺序做四件事:
this.inbox.claim(target, position.turn)——:889await this.loopCtx.systemPrompt.assemble(...)——:890renderContextSections(assembly)/joinContextSections(sections)/this.runtimeContext.project(...)——:892–:893this.dispatch.waterfall("agent/pre-step", …)——:894
你的日志里记到了 agent/inbox/spliced,而这条事件正是取件那一步发出的。也就是说 :889 之前已经跑完 —— 卡死只可能在 :890、:892–:893,或者挂在 :894 那个钩子上的某个监听器里。append / 会话写入这条路可以排除,收件箱也可以排除,但 pre-step 钩子不能 —— 这就要说到下一件。
自动压缩就跑在那个钩子里
compaction-basic/lib/index.js:798 订阅了 agent/pre-step,在每一步之前调用 compactIfNeeded(agent, "pressure", signal)(:800,定义在 :873)。它会对整个会话做一次度量(measure(),:878),一旦越过阈值就选出一段(selectCompactableRange,:393)并调用摘要(summarize,:858)。
有一条对读日志有影响:这个钩子里抛出的异常会被吞掉,只留一行警告 ——
step compaction failed: <message>; continuing the turn
—— 所以压缩这条路出问题不会让轮次失败,只会在日志里留一行警告。
"没有 request/header" 能排除什么
request/header 只有 dsh-agent-loop 会写(:1177、:1182、:1187),也就是轮次自己那次请求走的路径。而压缩的摘要调用是它自己直接发的 ctx.llm.stream()(compaction-basic/lib/index.js:302,由 :858 的 summarize 调用),不产生 request/header。
所以你的推断对轮次本身那次请求成立:卡死不在那次请求上。但它不能单独排除"压缩侧发起过一次模型调用",因为那种调用在日志里不会以 request/header 的形式出现。这里我们说的是代码路径,不是 profile —— 我们没有复现你的会话。
你的第二半已经有帖了:#6671,而且已经修了
"阈值与输出预留"这条,2026-09-15 已经有人报过:#6671(6 条评论,其中一条逐行核对了 resolveCompactSpec 与适配器的两个默认值),#5123 属同一族。我们这边唯一能补的是那个线程里没有的那条读数 —— 哪个已发布版本首次包含这处改动。
09-18 到 09-19 之间有六次提交动了 packages/compaction/compaction-basic/src,其中:
0fadb08fb—— gate pressure on the message budget, not the whole window555b664b0—— reserve configurable 64K pressure headroom9b016b642—— require an explicit output reservationec7030afd—— default summary output cap to headroom
我们下载了各版本已发布的 tarball,把 thresholdTokens 逐版本对比:
| 版本 | thresholdTokens |
|---|---|
0.1.5-rc.2 / 0.1.5-rc.3 / 0.1.6-alpha.1 / 0.1.6-alpha.2 |
Math.floor(contextWindow * policy.thresholdRatio)(在 :111) |
0.1.7-alpha.1 / 0.1.7-alpha.2 / 0.1.7-rc.1 / 0.1.7-rc.2 |
Math.floor(Math.min(contextWindow * policy.thresholdRatio, pressureBudgetTokens))(在 :132),函数签名也改成收 reservedCompletionTokens(resolveCompactSpec 定义在 :124) |
⇒ 首个包含该钳制的已发布版本是 0.1.7-alpha.1。至于怎么拿到它,现在的情况是两半:按 2026-09-24 的读数,这个包的 dist-tag 是 latest = 0.0.1-rc.3(不含钳制)、next = 0.1.7-rc.2(含钳制)、alpha = 0.1.7-alpha.2(含钳制)—— 也就是说 next 这条线能拿到修好的版本,而默认安装仍然落到没修的那一条上。dist-tag 我们带日期写,是因为这条线两天内动过两次(0.1.5-rc.3 → 0.1.7-rc.1 → 0.1.7-rc.2);而首个包含该钳制的版本不会变。
剩下那一半怎么切
如果在自动压缩不可能触发的前提下(不装 compaction 插件,或配置成压力远低于阈值)楔死仍然复现,那它就不在 pre-step 钩子里,而在 :890 / :892–:893 的装配路径上。跑一次就能把这两边分开。
若不复发,还有一个次要候选:dsh-compaction/lib/index.js:47–:58 在 surface.replaceGeneration 变化时会整段重建配对平衡缓存,所以若楔死与"此前发生过一次表面替换"相关,那就单独查这一条。
声明:本次实测与统计由我针对公开包产物完成;根因追查与文稿撰写由 AI 辅助;核验与发布由我本人负责。我没有工程背景 —— 若任何技术表述有误,请直接指出,我会对照工具链重新核实并更正。
本报告由 OfferKuai(Offer快)团队提交 —— 创始人:Zhaofeng(Yaming)。官网:https://www.offerkuai.com/ | 联系:contact@offerkuai.com
Still reproduced on 0.2.1-alpha.2 (macOS), with a native stackSame log shape as the report above, on a newer build. Two one-shot schedule deliveries and one later short user message all entered the same session (~2039 durable events, ~6.2 MB of decompressed JSON). Each time the web process stayed alive and kept listening, and stopped answering HTTP. What the log showsThe last durable events of the wedged turn are The schedule record itself is written as delivered before the process wedges. The scheduler is not the defect. Any turn start in this session is enough. A six-character user message reproduced it about three minutes after a clean restart. Where that sits in 0.2.1-alpha.2
On this version the waterfall still runs automatic compaction before the default context projection ( Native stack while wedgedmacOS, Node 22.23.2.
One core stayed around 80%. RSS climbed to about 4.4 GB. The log file stopped at startup. One What would separate the restA run with the compaction plugin absent, or with pressure held under the threshold, would show whether Environment: DSH 0.2.1-alpha.2, macOS, Node v22.23.2, web profile on 127.0.0.1:3080. The model route was a text-only DeepSeek model. History contained image blocks, so an |
Uh oh!
There was an error while loading. Please reload this page.
Summary
Starting a turn in a large session (~700k+ tokens of history) puts the host into a CPU-bound loop that blocks the Node event loop and never recovers on its own. The only way out is killing the process.
The web UI keeps showing its working indicator forever, and the HTTP endpoint stops answering entirely — so the in-UI stop button cannot reach the host either.
Observed twice, on two unrelated sessions. In one case the turn was a
/compact; in the other it was an ordinary user message. So this is not specific to the compaction command.Reproduction
Not yet reduced to a minimal case — reporting the two observed occurrences.
/compact.In both cases the session had grown to roughly 700k–800k tokens of surface history before the wedging turn.
Current behavior
Measurements taken while wedged (Windows):
session.v3.jsonl.zstd) receives no further events — frozen for 8+ minutes.GET http://127.0.0.1:3080/times out: the event loop is blocked, so the host cannot serve anything at all.The most useful clue: the last recorded event is
turn/start(plus theagent/inbox/splicedthat consumed the prompt). There is nostep/start, norequest/header, and noassistant/attemptafterward.That means the runaway happens before any model request is issued — in turn preparation / context assembly / measurement, not in the LLM call. A normal long model call would be async and would leave the event loop responsive.
Expected behavior
Either the turn proceeds, or it fails with an error. It should not be possible for a turn to block the host's event loop with no timeout and no recovery path.
Environment
llm-deepseek(DeepSeek provider)Possibly related: the default completion reserve is larger than the compaction threshold leaves room for
Flagging this separately because I can reproduce it cleanly and it may share a root cause — it is also why my sessions grew large enough to hit the wedge above.
dsh-llm-deepseekdefaults toDEFAULT_MAX_TOKENS = 256e3, and the automatic compaction threshold does not account for that reserve:With
contextWindow = 1_000_000the compaction threshold is 800,000, while the request ceiling against the provider's real limit is1_048_576 − 256_000 = 792_576— i.e. below the threshold.So any session in the ~792k–800k band fails with
CONTEXT_WINDOW_EXCEEDEDbefore compaction ever gets a chance to run:Reproduced three times on the same session (turns 39, 41, 42), with slightly different message counts each attempt.
Local workaround that removed it completely: set an explicit top-level
maxTokens: 65536for thellm-deepseeknamespace (the per-model value would only cover one model). Request ceiling becomes 983,040 against a threshold of 800,000.Local workaround for the wedge itself
Raising the compaction threshold's safety margin by lowering the model
contextWindow(which the threshold is computed from) — not a fix, just moving the point where sessions stop growing. The wedge itself remains reachable.All reactions