Merge remote-tracking branch 'origin/master' into fix/stats

This commit is contained in:
07akioni
2026-08-12 21:22:05 +08:00
465 changed files with 11350 additions and 1149 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.md
2026-08-11-bounded-background-task-admission.md: 24512a87f554cd2d775fe76c5a6e5a700a51f2e4
2026-08-11-bounded-background-task-admission.zh.md: dc3abaf4a64a4dc5fe5cacaabd3c29e278874646

View File

@@ -0,0 +1,55 @@
# Agent Note: Bounded background task admission
Status: implemented
English | [中文](2026-08-11-bounded-background-task-admission.zh.md)
## Problem
A model can start background Bash, PowerShell, PTY operations, and one-shot subagents in separate tool calls and later turns. The agent loop's `maxParallelToolCalls` limits only calls still executing inside one step; each background producer returns a task id immediately, so repeated starts can grow live processes or child work without bound.
The process-local task registry already owns the exact task owner and the authoritative lifecycle state, but it retained terminal history beside live records and had no admission policy. Releasing capacity when cancellation was requested would also be incorrect: a `stopping` producer may still own its process, PTY, or child until `TaskHooks.done` settles.
## Decision
`LocalTaskService` owns a `maxConcurrentTasksPerOwner` configuration field. It accepts positive safe integers, defaults to `10`, and is available through the provider's Cordis schema, the typed `agent-spine-demo` bundle, and the ACP app configuration. The bundle transports the value; the process-local provider owns its meaning.
The [generic task runtime decision](../architecture/2026-06-20-generic-long-running-tool-runtime.md) owns the shared Task lifecycle and control API; this note owns the process-local admission policy.
`start()` performs admission after the existing task-controller, task-field, and live-owner checks and before `TaskStart.run()`. It derives the active count from the registry's current records instead of storing another counter:
| Record | Occupies capacity | Release fact |
|---|---:|---|
| `running` | yes | producer `done` settles |
| `stopping` | yes | producer `done` settles |
| `completed`, `killed`, or `failed` | no | already terminal |
Owned tasks are bucketed by exact `Agent` object identity, matching owner cleanup. Replacement agents that reuse a session id receive an independent bucket. Tasks without an owner share one service-level bucket, so omitting ownership is not an unlimited bypass.
When the bucket is full, `start()` throws before producer execution and task-id allocation. The diagnostic includes the current limit and tells the model to use `task_kill`, wait until the task finishes stopping, and retry. Rejection creates no execution resource, queue entry, reservation, or public task record; a later successful start receives the next ordinary per-kind id.
Owner and service disposal keep their existing order: request cancellation, retain `stopping` occupancy while producers release resources, await settlement, then remove records. The admission policy therefore follows the same lifecycle fact used by reads, notices, and cleanup rather than treating a cancellation request as resource release.
Continuable background subagents remain outside this budget. They own durable child sessions and live Activations rather than Task records, so limiting them requires a separate result and lifecycle contract. This decision also adds no Task snapshot, session-log, wire, persistence, process-wide CPU or memory budget, queue, priority, preemption, or automatic oldest-task termination.
## Verification
The task-provider suite covers the default and explicit limits, producer-before rejection, unchanged id counters, `stopping` occupancy, every terminal release state, exact-owner isolation, same-session replacement objects, the shared unowned bucket, invalid configuration, owner cleanup, and service teardown. Spine and ACP composition tests pin typed forwarding. A keyless ACP replay boots the real Loader composition with a limit of one, starts one real background Bash process, observes the second start's actionable error, stops the first task by its returned id, and verifies that the rejected producer's marker file was never created.
## Alternatives considered
**Rely on `maxParallelToolCalls`.** Rejected because a background tool call releases its step slot as soon as it returns a task id; the setting cannot bound work that remains live across later steps and turns.
**Release capacity when `task_kill` succeeds.** Rejected because successful cancellation only changes the task to `stopping`. The producer may still hold the resource until `done` settles, so admitting a replacement immediately would exceed the configured live-resource bound.
**Use one global process bucket.** Rejected because one busy agent would deny unrelated sessions, while unowned host work still needs an explicit bounded bucket. Exact owner identity already defines the cleanup lifecycle and supplies the correct partition.
**Queue, preempt, or terminate the oldest task.** Rejected because each policy adds ordering, ownership, and cancellation behavior beyond the requested fail-closed limit. An explicit rejection lets the model decide which work is no longer needed through the existing `task_kill` control.
**Maintain a mutable active-count map.** Rejected because the registry already holds the authoritative records and statuses. A second count would require rollback and settlement synchronization while providing no user result that a direct derivation lacks.
## Consequences
One exact owner cannot keep creating Task-backed live resources indefinitely, and unrelated owners retain independent allowances. A slow stop keeps a bucket full until `done` settles, which is deliberate: the configured number bounds work that may still own resources, not cancellation requests. A producer whose `cancel` returns but whose `done` never settles holds one slot for the rest of the service lifetime and can stall teardown because the registry cannot safely infer resource release.
Admission scans the process-local registry on each start. The cost grows with retained Task history, accepted in exchange for one state authority and a default limit small enough to bound the common live set. Terminal history remains available to existing reads and listings without consuming capacity.

View File

@@ -0,0 +1,55 @@
# Agent Note: 有界后台任务准入
Status: implemented
[English](2026-08-11-bounded-background-task-admission.md) | 中文
## 问题
模型可以在不同工具调用和后续回合中启动后台 Bash、PowerShell、PTY 操作与一次性 subagent。agent loop 的 `maxParallelToolCalls` 只限制单个步骤中尚未返回的调用;每个后台生产方会立即返回 task id,因此反复启动会让仍存活的进程或子工作无限增长。
进程内任务注册表已经拥有确切任务 owner 与权威生命周期状态,但终止历史和实时记录保存在一起,且没有准入策略。在请求取消时立即释放容量也不正确:处于 `stopping` 的生产方仍可能拥有进程、PTY 或子任务,直到 `TaskHooks.done` 结算。
## 决策
`LocalTaskService` 拥有 `maxConcurrentTasksPerOwner` 配置字段。它只接受正的安全整数,默认值为 `10`,并通过 Service provider 的 Cordis schema、typed `agent-spine-demo` 组合包与 ACP 应用配置提供。组合包只传输该值;其含义归进程内 Service provider 所有。
[通用任务运行时决策](../architecture/2026-06-20-generic-long-running-tool-runtime.md)拥有共享 Task 生命周期与控制 API;本记录只拥有进程内准入策略。
`start()` 在现有任务控制器、任务字段与存活 owner 检查之后、`TaskStart.run()` 之前执行准入。它从注册表当前记录派生活动数量,而不保存另一份计数:
| 记录 | 占用容量 | 释放事实 |
|---|---:|---|
| `running` | 是 | 生产方 `done` 结算 |
| `stopping` | 是 | 生产方 `done` 结算 |
| `completed`、`killed` 或 `failed` | 否 | 已经终止 |
有 owner 的任务按确切 `Agent` 对象身份分桶,与 owner 清理保持一致。复用同一会话 id 的替代 agent 获得独立桶。无 owner 的任务共享一个服务级桶,因此省略 owner 不会成为无界旁路。
桶已满时,`start()` 会在生产方执行和 task id 分配前抛出异常。诊断包含当前上限,并告诉模型使用 `task_kill`、等待任务完全停稳后再重试。拒绝不会创建执行资源、排队项、预留或公开任务记录;后续成功启动仍会取得按 kind 正常递增的下一个 id。
owner 与服务释放保留现有顺序:请求取消,在生产方释放资源期间继续让 `stopping` 占位,等待结算,然后移除记录。因此,准入策略遵循读取、通知与清理共同使用的同一生命周期事实,而不会把取消请求误当成资源释放。
可继续后台 subagent 仍不纳入此预算。它们拥有持久 child session 与实时 Activation,而不是 Task 记录;限制它们需要独立的用户结果与生命周期约定。本决策也不会新增 Task 快照、会话日志、wire、持久化、进程级 CPU 或内存预算、队列、优先级、抢占或自动终止最旧任务。
## 验证
任务 Service provider 测试覆盖默认与显式上限、生产方执行前拒绝、id 计数器不变、`stopping` 占位、每种终态释放、确切 owner 隔离、同会话替代对象、共享无 owner 桶、非法配置、owner 清理和服务拆除。spine 与 ACP 组合测试固定 typed 转发。一条 keyless ACP 回放以 1 为上限启动真实 Loader 组合,启动一个真实后台 Bash 进程,观察第二次启动返回可操作错误,按返回的 task id 停止第一个任务,并验证被拒绝生产方的标记文件从未生成。
## 曾考虑的替代方案
**依赖 `maxParallelToolCalls`。**否决,因为后台工具调用一返回 task id 就会释放其步骤槽位;该设置无法限制在后续步骤和回合中继续存活的工作。
**在 `task_kill` 成功时释放容量。**否决,因为取消成功只会把任务改为 `stopping`。生产方在 `done` 结算前仍可能持有资源,立即准入替代任务会突破已配置的实时资源上限。
**使用一个全局进程桶。**否决,因为一个繁忙 agent 会拒绝无关会话,而无 owner 的宿主工作仍需要一个明确的有界桶。确切 owner 身份已经定义清理生命周期,并提供正确分区。
**排队、抢占或终止最旧任务。**否决,因为每种策略都会增加超出 fail-closed 上限要求的顺序、所有权和取消行为。显式拒绝让模型通过现有 `task_kill` 控制自行决定哪些工作不再需要。
**维护一张可变活动计数表。**否决,因为注册表已经保存权威记录与状态。第二份计数需要回滚和结算同步,却无法提供直接派生所缺少的用户结果。
## 后果
单个确切 owner 无法再无限创建由 Task 承载的实时资源,无关 owner 则保留独立额度。缓慢停止会让桶保持满载直到 `done` 结算,这是有意行为:配置值限制的是仍可能拥有资源的工作,而不是取消请求。如果生产方的 `cancel` 返回后始终不结算 `done`,它会在服务剩余生命周期内持续占用一个名额并阻塞销毁,因为注册表无法安全推断资源已经释放。
每次启动都会扫描进程内注册表。成本随保留的 Task 历史增长;为了保持单一状态权威,并利用足以约束常见实时集合的较小默认值,接受这一代价。终止历史仍可供现有读取与列表使用,但不消耗容量。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md
2026-08-11-synchronous-subprocess-exit-cleanup.md: fba5014d67f5152d6f8e42b3b41c1bbd20c7ede3
2026-08-11-synchronous-subprocess-exit-cleanup.zh.md: 33e13b7a1af9a943a266ea3ec979bf14e24f3802

View File

@@ -0,0 +1,51 @@
# Agent Note: Synchronous cleanup of managed subprocesses on host exit
Status: implemented
English | [中文](2026-08-11-synchronous-subprocess-exit-cleanup.zh.md)
## Problem
The local subprocess provider owns ordinary detached process trees and terminal sessions, but it previously reached them only through asynchronous Cordis disposal. A fatal launcher may call `process.exit()` before that disposal finishes: the [fail-loud release](2026-07-31-fail-loud-releases-the-terminal.md) waits at most two seconds, while a local process can have a longer termination grace. Once Node enters its synchronous exit phase, pending promises and escalation timers do not continue, so a TERM-resistant child can outlive the host and keep CPU, memory, or ports. Some ACP, JSON-RPC, and SDK entry points also have no root release callback.
The public subprocess seam correctly promises awaited quiescence during normal disposal. The defect is a separate final host-exit path below that seam, not a reason to weaken the normal lifecycle or duplicate process ownership in every launcher.
## Decision
`LocalSubprocessService` installs one synchronous Node `exit` listener in its Cordis effect. The same effect removes the listener only after normal disposal settles. Ordinary and terminal handles remain in the service's existing live sets while asynchronous cleanup is pending, so a shorter outer exit bound still sees and force-terminates them. If awaited disposal reports a cleanup failure, the service invokes the same synchronous final operations before clearing the sets and removing the listener.
The listener uses local-only final operations that are absent from the public `SubprocessHandle` and `SubprocessTerminalHandle` interfaces:
- An ordinary handle immediately sends SIGKILL to its detached POSIX process group or runs synchronous `taskkill /PID <pid> /T /F` on Windows.
- A terminal handle synchronously signals every captured and currently observable descendant with SIGKILL, kills the PTY root, then rescans once for members that became observable during that boundary.
- The service contains each target's failure and continues with the remaining handles. The callback creates no promise or timer, writes no diagnostic, and does not change the original exit code or error.
Normal disposal remains the [subprocess seam's](../architecture/2026-07-26-subprocess-seam.md) terminate-and-join path: ordinary trees receive TERM, the configured grace, then KILL, and every ordinary or terminal cleanup is awaited to quiescence. The synchronous path requests final termination but does not publish a completion result or claim the OS tree is already gone when the callback returns. Remote providers retain their own sandbox ownership and do not inherit a local Node listener.
| Host path | Local provider action | Completion evidence |
| --- | --- | --- |
| Normal Cordis disposal | Cooperative termination, bounded escalation, and awaited ordinary/terminal cleanup | Every owned handle reaches quiescence before disposal settles |
| `process.exit()`, default uncaught exception, or default unhandled rejection | Synchronous final signals against the service's current live sets | External observation after the host exits |
| Default termination for an unhandled `SIGTERM`, `SIGINT`, or `SIGHUP`; `SIGKILL`; fatal OOM; `process.abort()`; native crash; or power loss | No in-process action can run | External supervisor, container, or OS ownership is required unless the application installs a signal handler that performs disposal or calls `process.exit()` |
## Verification
A parent test starts an isolated TypeScript host through the repository source launcher, waits until exact root and descendant process identities are observable, then allows the host to take each fatal path. Direct exit, default uncaught exception, and default unhandled rejection cover ordinary TERM-resistant trees; direct exit also covers a real terminal root and descendant. The parent asserts the original host exit category and waits for every recorded process to disappear, while failure cleanup targets only recorded identities or the recorded Windows tree.
Unit evidence pins synchronous POSIX group and Windows taskkill delivery, terminal scans before and after the PTY root kill, repeated finalization, per-target failure containment, normal TERM-to-KILL disposal, live-set retention during pending disposal, and listener removal after disposal.
## Alternatives considered
**Rely only on launcher release callbacks.** Rejected because not every entry point supplies one, and a bounded release can still end before the subprocess provider's grace and timers complete.
**Call the existing asynchronous `terminate()` methods from the `exit` listener.** Rejected because Node does not await exit listeners; promises, timers, output draining, and quiescence polling cannot finish after the callback returns.
**Add a public raw `forceKill()` operation to subprocess handles.** Rejected because consumers need one cooperative termination contract. Immediate final termination is an implementation responsibility used only by the local service's host-exit owner.
**Delegate every failure mode to an external supervisor.** Rejected as the only solution because Node exposes a reliable synchronous callback for several common fatal paths and the provider already owns the exact targets. External ownership remains necessary when JavaScript cannot run.
## Consequences
Each active local subprocess service contributes one process-global exit listener, removed with the service effect. Fatal exit gives up grace, output draining, and an in-process quiescence proof in exchange for issuing the strongest available local termination before the host disappears. Normal disposal keeps those guarantees and costs unchanged.
The listener cannot cover failures that do not execute JavaScript, and it cannot discover a terminal descendant that escaped before the provider ever observed it; that separate ownership gap remains tracked by Issue #1726.

View File

@@ -0,0 +1,51 @@
# Agent Note: 宿主退出时同步清理受管子进程
Status: implemented
[English](2026-08-11-synchronous-subprocess-exit-cleanup.md) | 中文
## Problem
本地 subprocess provider拥有普通 detached进程树和 terminal session,但此前只能通过异步 Cordis dispose触及它们。致命 launcher可能在 dispose完成前调用 `process.exit()`:[fail-loud release](2026-07-31-fail-loud-releases-the-terminal.md)最多等待两秒,而本地进程可以拥有更长的终止宽限期。Node进入同步退出阶段后,待处理的 Promise与升级 timer不会继续执行,因此忽略 TERM的子进程可能比宿主存活更久,继续占用 CPU、内存或端口。部分 ACP、JSON-RPC和 SDK入口也没有 root release回调。
公共 subprocess seam在正常 dispose期间承诺等待完全停稳,这项承诺是正确的。缺陷属于 seam之下另一条最终宿主退出路径,不应削弱正常生命周期,也不应让每个 launcher重复保存进程所有权。
## Decision
`LocalSubprocessService`在自身 Cordis effect中安装一个同步 Node `exit` listener。只有正常 dispose结算后,同一 effect才移除该 listener。异步清理仍在等待时,普通和 terminal handle继续保留在服务已有的存活集合中,因此更短的外层退出上限仍能看到并强制终止它们。等待中的 dispose报告清理失败时,服务会在清空集合并移除 listener前调用同一组同步最终操作。
该 listener使用本地实现私有的最终操作;公共 `SubprocessHandle`和 `SubprocessTerminalHandle`接口不包含这些操作:
- 普通 handle立即向 detached POSIX进程组发送 SIGKILL,或在 Windows同步运行 `taskkill /PID <pid> /T /F`。
- Terminal handle同步向全部已捕获及当前可观察的后代发送 SIGKILL,终止 PTY root,然后再扫描一次并终止在该边界期间变得可观察的成员。
- 服务分别包含每个目标的失败并继续处理其余 handle。回调不会创建 Promise或 timer,不写诊断,也不改变原始退出码或错误。
正常 dispose继续使用[subprocess seam](../architecture/2026-07-26-subprocess-seam.md)的先终止再等待退出路径:普通进程树先接收 TERM,经过配置的宽限期后再接收 KILL,并等待每个普通或 terminal清理达到完全停稳。同步路径只请求最终终止,不发布完成结果,也不声称回调返回时 OS进程树已经消失。远程 provider继续由其 sandbox独立拥有,不继承本地 Node listener。
| 宿主路径 | 本地 provider动作 | 完成证据 |
| --- | --- | --- |
| 正常 Cordis dispose | 协作式终止、有界升级,并等待普通/terminal清理 | dispose结算前,每个自有 handle均达到完全停稳 |
| `process.exit()`、默认未捕获异常或默认未处理 rejection | 对服务当前存活集合发送同步最终信号 | 宿主退出后的外部观察 |
| 未安装 handler 时由 `SIGTERM`、`SIGINT` 或 `SIGHUP` 默认终止;`SIGKILL`;fatal OOM;`process.abort()`;native crash;或断电 | 进程内操作无法运行 | 必须由外部 supervisor、容器或 OS 所有权负责;应用安装执行 dispose 或调用 `process.exit()` 的信号 handler 时除外 |
## Verification
父测试通过仓库 source launcher启动隔离的 TypeScript宿主,等待精确 root与后代进程身份可观察后,再允许宿主进入各条致命路径。直接退出、默认未捕获异常和默认未处理 rejection覆盖忽略 TERM的普通进程树;直接退出还覆盖真实 terminal root与后代。父测试断言原始宿主退出类别,并等待所有已记录进程消失;失败清理只针对已记录身份或已记录的 Windows进程树。
单元证据固定同步 POSIX进程组与 Windows taskkill投递、PTY root终止前后的 terminal扫描、重复最终清理、逐目标失败包含、正常 TERM到 KILL dispose、dispose等待期间保留存活集合,以及 dispose后移除 listener。
## Alternatives considered
**只依赖 launcher release回调。** 拒绝,因为不是每个入口都会提供该回调,而且有界 release仍可能在 subprocess provider的宽限期与 timer完成前结束。
**在 `exit` listener中调用现有异步 `terminate()`。** 拒绝,因为 Node不会等待 exit listener;回调返回后,Promise、timer、输出排空与停稳轮询都无法完成。
**向公共 subprocess handle增加 raw `forceKill()`操作。** 拒绝,因为消费方只需要一项协作式终止约定。立即最终终止属于实现职责,只由本地服务的宿主退出 owner使用。
**把所有故障模式交给外部 supervisor。** 不接受将其作为唯一方案,因为 Node为几条常见致命路径提供可靠的同步回调,而 provider已经拥有精确目标。JavaScript无法运行时仍必须依赖外部所有权。
## Consequences
每个有效的本地 subprocess service都会贡献一个进程全局 exit listener,并随服务 effect移除。致命退出放弃宽限、输出排空与进程内停稳证明,以换取宿主消失前发出本地可用的最强终止操作。正常 dispose的保证与成本保持不变。
listener无法覆盖不执行 JavaScript的故障,也无法发现 provider首次观察前已经逃逸的 terminal后代;该独立所有权缺口仍由 Issue #1726跟踪。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-12-max-tokens-turn-end-notice.md
2026-08-12-max-tokens-turn-end-notice.md: bdb34d67a6d8271089af04f6bcd0f046ef7cdea6
2026-08-12-max-tokens-turn-end-notice.zh.md: 9b9c679972570e791b607330195810d68f04c803

View File

@@ -0,0 +1,27 @@
# Agent Note: The chat flow surfaces a max-tokens turn end
Status: implemented
English | [中文](2026-08-12-max-tokens-turn-end-notice.zh.md)
## Problem
The agent loop records `max-tokens` as its own `turn/end` reason, but no user surface consumed it. In the Web chat flow only `reason.kind === 'error'` built a conversation node, and the unknown-surface fallback claims append-surface events only, so a turn the provider cut at its output cap ended with no visible sign: the truncated answer read as a normal completion, and the user had no way to tell why the run stopped (issue #1522).
## Decision
A `turn-max-tokens` conversation node Definition matches `turn/end` with `reason.kind === 'max-tokens'` and materializes a persistent chat row at the turn position: a warning StateDot, a localized title, and guidance that the truncated output is preserved and sending "continue" resumes in a new turn. The node derives from the durable session event alone, so refresh, restore, and history replay rebuild it identically. It shows no token numbers: the event carries none, and the notice must not fabricate budget data the provider did not report.
The renderer registers under the keyed `conversation.chat.node` seat like every chat row, and the legacy chat-snapshot contribution includes the node. The fixture history gained a max-tokens sample turn (72; the image and todo turns shifted to 73 and 74), and an assembled keyless snapshot pins the dot state, title, and hint, so a regression that routes max-tokens through the error presentation or silences it again changes a golden.
## Alternatives considered
**Extending `turn-error` with a max-tokens arm** — rejected: the acceptance for issue #1522 requires that max-tokens not read as a provider error; a shared node kind couples the two presentations, and the two reasons carry different data (an error payload versus nothing).
**A turn-tail marker instead of a flow row** — rejected: the tail renders closing chrome for a finished turn and its actions collapse on later turns, while the truncation notice must stay at the turn that was cut and remain visible in history without interaction.
**A continue or retry action button on the notice** — deferred: resuming has open semantics (new turn versus same-turn splice, old-output retention rules) that issue #1522 explicitly leaves out of scope; guidance text carries the safe next step without committing to an action contract.
## Consequences
Max-tokens turn ends are visible, localized, and distinct from both errors and normal completion across live streaming, reload, and replay. The fixture renumbering cost two comment updates in dependent snapshots, and anything pinning fixture turn numbers must count from the new layout. Surfaces other than the Web chat flow (ACP and SDK consumers) keep mapping the reason through their own presentations and are unchanged.

View File

@@ -0,0 +1,27 @@
# Agent Note: 聊天流展示 max-tokens 结束的轮次
Status: implemented
[English](2026-08-12-max-tokens-turn-end-notice.md) | 中文
## Problem
agent loop 已把 `max-tokens` 记录为独立的 `turn/end` 原因,但没有任何用户表面消费它。Web 聊天流中只有 `reason.kind === 'error'` 会生成会话节点,unknown-surface 兜底又只接管 append-surface 事件,于是被提供方在输出上限处截断的轮次没有任何可见迹象:被截断的回答看起来和正常完成一样,用户无从得知运行为何停止(issue #1522)。
## Decision
新增 `turn-max-tokens` 会话节点 Definition,匹配 `reason.kind === 'max-tokens'` 的 `turn/end`,在该轮位置生成一条持久聊天行:warning 状态的 StateDot、本地化标题,以及说明已截断输出会保留、发送“继续”可在新一轮接着输出的指引。节点只从持久会话事件推导,因此刷新、恢复和历史回放会重建出完全一致的结果。提示不显示任何 token 数字:事件本身不携带数量,提示也不得伪造提供方未报告的预算数据。
渲染器与其他聊天行一样注册在按 kind 分发的 `conversation.chat.node` 槽位下,legacy chat-snapshot 投影也包含该节点。fixture 历史新增了一个 max-tokens 样本轮(72,图片轮和 todo 轮顺移为 73、74),并有一条 assembled keyless snapshot 钉住圆点状态、标题和指引文案,把 max-tokens 路由回错误样式或再次静默的回归都会改动 golden。
## Alternatives considered
**在 `turn-error` 上加一个 max-tokens 分支** — 否决:issue #1522 的验收要求 max-tokens 不得呈现为普通 provider error;共用节点会耦合两种呈现,且两种原因携带的数据不同(一个有错误负载,一个没有)。
**用 turn-tail 标记代替独立聊天行** — 否决:turn-tail 渲染的是完成轮次的收尾信息,其操作会在后续轮次折叠,而截断提示必须停留在被截断的那一轮,并且在历史中无需交互即可看到。
**在提示上放继续或重试按钮** — 暂缓:恢复输出的语义尚未确定(新开一轮还是同轮续写、旧输出保留规则),issue #1522 明确把它排除在范围外;指引文字已给出安全的下一步,不必先固定一个操作契约。
## Consequences
max-tokens 结束在实时流、刷新和回放中都可见、已本地化,并与错误和正常完成明确区分。fixture 重编号需要更新两处依赖 snapshot 的注释,之后钉 fixture 轮次号的改动要按新布局计数。Web 聊天流之外的表面(ACP 和 SDK 消费方)仍按各自的呈现映射该原因,本次不变。