From df30c62e2b062da14fa0366728d821f416d72e57 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 7 Aug 2026 14:54:31 +0800 Subject: [PATCH] fix(agent-loop): latch wakes landing in the cancel-convergence window --- ...07-16-explicit-turn-cancellation.i18n.yaml | 4 +- .../2026-07-16-explicit-turn-cancellation.md | 2 +- ...026-07-16-explicit-turn-cancellation.zh.md | 2 +- ...07-cancel-convergence-wake-latch.i18n.yaml | 6 + ...026-08-07-cancel-convergence-wake-latch.md | 29 ++++ ...-08-07-cancel-convergence-wake-latch.zh.md | 29 ++++ docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/cordis-catalog/events.md | 24 +-- docs/core-data-structures/core.i18n.yaml | 4 +- docs/core-data-structures/core.md | 6 +- docs/core-data-structures/core.zh.md | 6 +- docs/event-producer-consumer.md | 24 +-- docs/persistence-catalog.md | 2 +- packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/README.zh.md | 2 +- packages/core/agent-loop/src/agent.ts | 51 ++++-- packages/core/agent-loop/tests/cancel.spec.ts | 154 ++++++++++++++++-- packages/core/agent-loop/tests/loop.spec.ts | 73 ++++++++- .../core/agent-loop/tests/mock-adapter.ts | 18 +- packages/core/agent/src/types.ts | 6 +- 23 files changed, 379 insertions(+), 77 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml index 820299cf2e..2f7893a09f 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml @@ -2,5 +2,5 @@ # 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/architecture/2026-07-16-explicit-turn-cancellation.md -2026-07-16-explicit-turn-cancellation.md: ca56c77a097e3008a50c2aec24040a4f4b6f0ba3 -2026-07-16-explicit-turn-cancellation.zh.md: bf410e5c7284a9c9914edbd14445074e71dd6943 +2026-07-16-explicit-turn-cancellation.md: 2b7cb8cc77184edf1331764d28aefe748c1614a1 +2026-07-16-explicit-turn-cancellation.zh.md: 68089e2c48d239afbff4c10cba5a202b4b6ff262 diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md index ca56c77a09..2b7cb8cc77 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md @@ -18,7 +18,7 @@ An interrupted live turn ends with the coarse durable `{ kind: 'aborted' }` outc AgentLoop privately owns one `TurnCancellation` per prospective turn. It installs the holder before notifying `agent/status = running`, retains its single `AbortController` through inbox claim, `agent/pre-step`, prompt assembly, every step, model and tool execution, and `agent/turn-stopping`, then clears the exact holder immediately before publishing `turn/end`. Terminal event observers and the following durability flush therefore cannot cancel already-completed turn work even though driver status may remain `running` until the flush settles. Every participating method, event, and request value receives that same explicit signal; the next turn receives a fresh signal. -The driver keeps only a cause-less pre-run marker for queued work cancelled before a turn is claimed. An effective `cancel()` emits the observe-only `agent/cancel-requested` notification with its resolved typed cause before clearing queued and steering work or aborting the holder; notification failures cannot veto the stop, and an idle call emits nothing. Work synchronously queued by a notification observer is included in that clear, while work queued by a later signal abort observer belongs to the next turn. If a `running` listener synchronously cancels old work and sends a replacement, the driver discards the aborted holder and creates a fresh one for the replacement. Repeated cancellation is first-wins for the active holder, while later calls may still clear newly queued pending work. +The driver keeps only a cause-less pre-run marker for queued work cancelled before a turn is claimed. An effective `cancel()` emits the observe-only `agent/cancel-requested` notification with its resolved typed cause before clearing queued and steering work or aborting the holder; notification failures cannot veto the stop, and an idle call emits nothing. Work synchronously queued by a notification observer is included in that clear, while work queued by a later signal abort observer is latched and runs when the aborted activity converges to idle — a `disposed` cancel leaves it parked ([cancel-convergence wake latch](../bug-fix/2026-08-07-cancel-convergence-wake-latch.md)). If a `running` listener synchronously cancels old work and sends a replacement, the driver discards the aborted holder and creates a fresh one for the replacement. Repeated cancellation is first-wins for the active holder, while later calls may still clear newly queued pending work. The explicit event signatures pass a single payload object: agent-scoped events carry `agent` and `signal` in the payload with `next` last, and the remaining seams keep `signal` immediately before a waterfall's final `next`. `PreStepContext` and `RequestFailureContext` are retired, with their fields folded into the `agent/pre-step` and `agent/request-error` payloads ([payload-object events](2026-08-06-agent-event-payload-objects.md)). Pre-step entry, request configuration, request-error recovery, model generation, tool execution, approval, turn stopping, and subagent or workflow requests all receive the current signal. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's process-group kill and join boundary. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn. diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md index bf410e5c72..68089e2c48 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md @@ -18,7 +18,7 @@ Agent 拥有仅用于运行时的 `AgentCancelCause` 联合类型 `{ kind: 'user AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它在通知 `agent/status = running` 前安装该持有者,使其中唯一的 `AbortController` 持续覆盖 inbox 领取、`agent/pre-step`、提示词组装、每个步骤、模型与工具执行以及 `agent/turn-stopping`;随后在发布 `turn/end` 前立即清除所安装的那个持有者。因此,即使驱动器状态可能在持久化刷新结算前保持 `running`,终态事件观察者及其后的持久化刷新也无法取消已完成的轮次工作。所有参与的方法、事件和请求值都会收到同一个显式 signal;下一个轮次会收到全新的 signal。 -对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 `cancel()` 会先发出仅供观察的 `agent/cancel-requested` 通知并携带最终确定的类型化取消原因,然后才清除排队工作和 steering(中途引导)工作或中止持有者;通知失败不能阻止此次停止,空闲状态下调用则不发出任何通知。通知观察者同步加入队列的工作也会被这次清除,而稍后由 signal 中止观察者加入队列的工作属于下一个轮次。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。 +对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 `cancel()` 会先发出仅供观察的 `agent/cancel-requested` 通知并携带最终确定的类型化取消原因,然后才清除排队工作和 steering(中途引导)工作或中止持有者;通知失败不能阻止此次停止,空闲状态下调用则不发出任何通知。通知观察者同步加入队列的工作也会被这次清除,而稍后由 signal 中止观察者加入队列的工作会被锁存,并在被中止的活动收敛到空闲时执行——`disposed` 取消则将其停放([取消收敛窗口唤醒锁存](../bug-fix/2026-08-07-cancel-convergence-wake-latch.md))。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。 显式事件签名传递单个 payload 对象:agent 作用域事件在 payload 中携带 `agent` 和 `signal`,`next` 位于最后;其余 seam 保持 `signal` 紧邻 waterfall(瀑布式事件)的最终 `next` 之前。`PreStepContext` 与 `RequestFailureContext` 已退役,其字段并入 `agent/pre-step` 与 `agent/request-error` 的 payload([payload-object 事件](2026-08-06-agent-event-payload-objects.md))。pre-step 进入决策、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.i18n.yaml new file mode 100644 index 0000000000..d31f344c27 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.i18n.yaml @@ -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-07-cancel-convergence-wake-latch.md +2026-08-07-cancel-convergence-wake-latch.md: fe00c78bdfadac0cc6c9c173fd04256f77a22051 +2026-08-07-cancel-convergence-wake-latch.zh.md: f76ad360e2d8b70f76c6baf53e2fde963a80b6c4 diff --git a/.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md b/.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md new file mode 100644 index 0000000000..fe00c78bdf --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md @@ -0,0 +1,29 @@ +# Agent Note: Latch wake-ups that land in the cancel-convergence window + +Status: implemented + +English | [中文](2026-08-07-cancel-convergence-wake-latch.zh.md) + +## Problem + +`Agent.cancel(cause, { keepInbox: true })` returns immediately after firing the abort signal, but the active driver may not have converged to `idle` yet: LLM stream teardown, tool cancellation, and the `turn/end` append all unwind asynchronously after `abort()` returns. A waking send arriving in that window was placed into `next-turn` while `wakeDriver()` returned early on the still-`running` phase, and the exiting driver never replayed the wake — the message stayed parked until another waking send arrived. The same dropped-wake window existed around aborted `runMaintenance` activities. Several tests enshrined the parked behavior ("waits for another wakeup"); the bug broke both `session.cancel` and the `subagent.interrupt` composition path (issue #1838). The owning cancellation and send contracts are the [explicit turn cancellation](../architecture/2026-07-16-explicit-turn-cancellation.md) and [unified send](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md) decisions; the production `keepInbox` consumer is [web stop preserves queue](2026-07-31-web-stop-preserves-queue.md). + +## Decision + +The `running` phase carries a `wakeRequested` latch, mirroring the existing `maintenance` phase field. `wakeDriver()` latches whenever the current activity cannot deliver the wake — a maintenance task never reads the queue, and an aborted activity converges without restarting — while a live driver needs no latch because it claims queued work itself. The exiting activity replays the latch at its own convergence boundary (`kick`'s `finally` and `runMaintenance`'s `finally`): this placement guarantees `turn/end N` lands before the replayed driver opens `turn/start N+1`, and that `whenIdle()` sees the replayed driver through its `activityDone` loop. The replay sites run only while `inbox.hasPending`, so a latched wake removed from the inbox before convergence does not start an empty driver. A wake sent while the agent is already idle keeps its turn boundary even when its message is cleared before the driver claims — that `idle → running → idle` transition is an observable contract: the goal-session driver's pause/disarm fallback fires on the `idle` transition after a cancelled reservation (CI caught this when the guard was moved into `wakeDriver()` and suppressed the boundary). `cancel()` without `keepInbox` clears the latch together with the inbox. + +The `signal.aborted` discriminator is load-bearing: it separates pre-abort queued work — which `keepInbox` parks for a later wake (acceptance criterion 1) — from post-abort explicit wakes, which must run after convergence. + +## Alternatives considered + +**Have `cancel()` set the phase to `idle` immediately.** Rejected: the driver is still unwinding, so this overlaps two drivers. The replay lives in the old driver's `finally`, which then never runs — 14 of 83 tests failed, several deadlocked. Repairing it requires identity-based phase ownership plus a turn-open quiescence barrier, which is strictly more machinery and is the latch in disguise. + +**Latch unconditionally for every non-idle wake.** Rejected: pre-abort wakes would auto-start after a `keepInbox` cancel, violating acceptance criterion 1; the "parks queued work" test and the error-window steering test both failed. + +**Replay through a chained promise (`activityDone.then(...)`).** Rejected: the replay would run outside the activity's own settlement, so `whenIdle()`'s loop can resolve before the replayed driver starts; fixing that requires replacing `activityDone` at send time and depends on microtask reaction ordering — more fragile than a synchronous flag. + +**Wait for quiescence in the subagent adapter.** Rejected by the issue scope: the cancel/wake state machine owns the fix, not a consumer. + +## Consequences + +The `running` phase gains a `wakeRequested` field; `cancel()` without `keepInbox` clears it alongside the inbox, and a `disposed` cancel never latches, so a wake landing after disposal begins stays parked and `whenIdle()` does not wait on a full model turn over the session being torn down. A wake arriving in the sub-microtask gap between the driver's final `hasPending` check and its exit still parks — no latch fires because the phase is `running` and not aborted; closing that gap requires the unconditional latch and is deliberately out of scope. Between the aborted turn and the replayed driver, status transitions emit a transient `idle → running` pair. A waking send whose message is cleared before any driver claims it still opens an empty completed turn, preserving the observable wake boundary. diff --git a/.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.zh.md b/.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.zh.md new file mode 100644 index 0000000000..f76ad360e2 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 锁存取消收敛窗口内到达的唤醒请求 + +Status: implemented + +[English](2026-08-07-cancel-convergence-wake-latch.md) | 中文 + +## 问题 + +`Agent.cancel(cause, { keepInbox: true })` 在触发 abort 信号后立即返回,但活动 driver 可能尚未收敛到 `idle`:LLM 流拆除、工具取消与 `turn/end` 落盘都会在 `abort()` 返回后异步展开。在该窗口内到达的唤醒 send 被放入 `next-turn`,而 `wakeDriver()` 对仍处于 `running` 的 phase 直接返回,退出的 driver 也从不重放这次唤醒——消息会一直停放到下一条唤醒 send 到达。被中止的 `runMaintenance` 活动周围也存在同样的唤醒丢失窗口。多个测试固化了停放行为(「等待下一次唤醒」);该缺陷同时破坏了 `session.cancel` 与 `subagent.interrupt` 组合路径(issue #1838)。拥有取消与发送契约的既有决策是[显式轮次取消](../architecture/2026-07-16-explicit-turn-cancellation.md)与[统一发送](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md);生产环境中的 `keepInbox` 消费方是[Web 停止保留队列](2026-07-31-web-stop-preserves-queue.md)。 + +## 决策 + +`running` phase 携带 `wakeRequested` 锁存,与既有的 `maintenance` phase 字段对称。`wakeDriver()` 在当前活动无法投递唤醒时锁存——maintenance 任务从不读取队列,被中止的活动收敛后不会重启——而存活的 driver 不需要锁存,因为它自己会认领排队的工作。退出中的活动在其自身收敛边界(`kick` 的 `finally` 与 `runMaintenance` 的 `finally`)重放锁存:这一位置保证 `turn/end N` 先于重放 driver 打开 `turn/start N+1` 落盘,并保证 `whenIdle()` 通过其 `activityDone` 循环看到重放 driver。两个重放点仅在 `inbox.hasPending` 时执行,因此收敛前被从 inbox 移除的锁存唤醒不会启动空 driver。而 agent 已处于 idle 时发送的唤醒,即使消息在 driver 认领前被清除,仍会打开自己的 turn 边界——这趟 `idle → running → idle` 转换是可观察契约:goal-session driver 的 pause/disarm 回退依赖取消预订后的 `idle` 转换触发(把守卫放进 `wakeDriver()` 后该边界被抑制,CI 发现了这一点)。不带 `keepInbox` 的 `cancel()` 会连同 inbox 一起清除锁存。 + +`signal.aborted` 判别项是承重的:它区分「中断前已排队的工作」——`keepInbox` 将其停放以待后续唤醒(验收条件 1)——与「abort 后显式的唤醒」,后者必须在收敛后执行。 + +## 备选方案 + +**让 `cancel()` 立即把 phase 置为 `idle`。** 不予采用:driver 仍在展开收尾,这会重叠两个 driver。重放逻辑位于旧 driver 的 `finally`,而该 `finally` 此后不再执行——83 个测试中有 14 个失败,多个死锁。修复它需要基于身份的 phase 所有权外加 turn 打开时的 quiescence 屏障,机制上严格更重,而且该屏障就是换了个形态的锁存。 + +**对每个非 idle 唤醒无条件锁存。** 不予采用:中断前的唤醒会在 `keepInbox` 取消后自动启动,违反验收条件 1;「停放排队工作」测试与错误窗口的 steering 测试双双失败。 + +**通过链式 promise(`activityDone.then(...)`)重放。** 不予采用:重放会运行在活动自身结算之外,`whenIdle()` 的循环可能在重放 driver 启动前就 resolve;修复它需要在 send 时同步替换 `activityDone`,并依赖微任务反应顺序——比同步 flag 更脆弱。 + +**在 subagent adapter 中等待 quiescence。** 被 issue 范围否决:修复由取消/唤醒状态机拥有,而不是消费方。 + +## 影响 + +`running` phase 新增 `wakeRequested` 字段;不带 `keepInbox` 的 `cancel()` 会连同 inbox 一起清除它,且 `disposed` 取消从不锁存——dispose 开始后到达的唤醒保持停放,`whenIdle()` 不会在拆除中的会话上等待一个完整模型 turn。落在 driver 最后一次 `hasPending` 检查与退出之间微任务间隙的唤醒仍会停放——没有锁存触发,因为 phase 是 `running` 且未 abort;关闭该间隙需要无条件锁存,刻意留作范围外。在被中止的 turn 与重放 driver 之间,状态转换会发出一次瞬态 `idle → running` 对。唤醒 send 的消息在任何 driver 认领前被清除时,仍会打开一个空的 completed turn,保留可观察的唤醒边界。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index ca91a80b7e..de15631197 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/architecture.md -architecture.md: 3080d21a24310cc8851d64719d862ec719150ab6 -architecture.zh.md: 6e2bf0c155c68e84580aecc6b7783eeffa25c640 +architecture.md: ea78faa62773a6b8ac98e5baab6e181ad6a3b7f0 +architecture.zh.md: 5a46dfaf84276de5f4dc0352a529bdd4e5d8c267 diff --git a/docs/architecture.md b/docs/architecture.md index 3080d21a24..ea78faa627 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -121,7 +121,7 @@ Pruning precedes summaries; overflow retries require durable progress. `agent/re Adapter selection, dispatch, and iteration failures become terminal error or aborted `finish` chunks. `agent/request-error` receives request coordinates, normalized `LlmFailure`, available retry policy, and signal; middleware and consumer errors remain outside recovery. Failed chunks commit neither messages nor tool calls. -Other failures use `agent/error`; cancellation and disposal beat recovery. Before request-header commit, the turn signal cancels capability preparation; undispatched tools get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. Effective `cancel(cause)` reports its cause before clearing and aborting; idle calls emit nothing. Durability distinguishes `aborted` cancellation from `disposed` teardown, which awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). +Other failures use `agent/error`; cancellation and disposal beat recovery. Before request-header commit, the turn signal cancels capability preparation; undispatched tools get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. Effective `cancel(cause)` reports its cause before clearing and aborting; idle calls emit nothing. Waking input that lands after the abort fires but before convergence runs at the driver's convergence boundary, while a `disposed` cancel leaves it parked ([cancel-convergence wake latch](../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md)). Durability distinguishes `aborted` cancellation from `disposed` teardown, which awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). Turn and step events are turn-enclosed; the loop appends `user/message` events only from entered batches inside a turn. A turn opens before the initial claim and pre-step, so rejection, empty input, cancellation, or failure closes a durable turn without any step events. Standalone `compact/* { turn: null }` events consume no turn, and their lock-time markers may interleave with inbox splices. Reload synthesizes interrupted turn ends; `session/end-seed` distinguishes stale compaction orphans from live locks. After close, only `agent/error` reports failures. Each turn has one [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 6e2bf0c155..5a46dfaf84 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -121,7 +121,7 @@ idle inject: 适配器选择、分发与迭代失败会成为 error 或 aborted 类型的终止 `finish` 分片。`agent/request-error` 接收请求坐标、标准化 `LlmFailure`、可用的重试策略和信号;middleware 与消费方错误仍在恢复之外。失败分片既不提交消息,也不提交工具调用。 -其他故障使用 `agent/error`;取消和资源释放优先于恢复。在提交请求头之前,轮次信号会取消功能准备;尚未分派的工具会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。实际生效的 `cancel(cause)` 会在清空队列和中止前报告原因;空闲调用不发事件。持久化层以 `aborted` 区分取消,以 `disposed` 区分会等待完全停稳的拆卸([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。 +其他故障使用 `agent/error`;取消和资源释放优先于恢复。在提交请求头之前,轮次信号会取消功能准备;尚未分派的工具会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。实际生效的 `cancel(cause)` 会在清空队列和中止前报告原因;空闲调用不发事件。abort 触发后、收敛前到达的唤醒输入会在 driver 的收敛边界执行,而 `disposed` 取消则将其停放([取消收敛窗口唤醒锁存](../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md))。持久化层以 `aborted` 区分取消,以 `disposed` 区分会等待完全停稳的拆卸([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。 轮次和步骤事件均位于轮次边界内;loop 只会在轮次内从进入步骤的批次追加 `user/message`。轮次会在首次领取与 pre-step 之前打开,因此拒绝、空输入、取消或失败会关闭一个不包含任何步骤事件的持久轮次。独立的 `compact/* { turn: null }` 事件不占用轮次,其锁定时刻标记可以与 inbox splice 交错。重新加载会为中断的轮次合成结束事件;`session/end-seed` 区分陈旧的压缩遗留项与活跃锁。关闭后仅由 `agent/error` 报告故障。每个轮次有一个 [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap)。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 92c2fade12..ae16d10f07 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -33,7 +33,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:154`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:158`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -53,7 +53,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:163`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:167`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -75,7 +75,7 @@ A step or turn errored. The machine reports a failure here even when the error h Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:285`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:289`](../../packages/core/agent/src/types.ts) ### `agent/inbox/claimed` — emit @@ -97,7 +97,7 @@ One message left the inbox inside its open turn. If the proposed step is rejecte Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:192`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:196`](../../packages/core/agent/src/types.ts) ### `agent/inbox/discarded` — emit @@ -116,7 +116,7 @@ One message was discarded from the live inbox. Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:200`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:204`](../../packages/core/agent/src/types.ts) ### `agent/inbox/inserted` — emit @@ -135,7 +135,7 @@ One message entered the live inbox. Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:181`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:185`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — waterfall @@ -158,7 +158,7 @@ Reject a proposed step or replace the messages that enter it. Calling `next()` p Types: [Agent](../core-data-structures/core.md) · [PreStepDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:226`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:230`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -182,7 +182,7 @@ Replace the frozen call configuration. `await next()` yields the config the mach Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:239`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:243`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -209,7 +209,7 @@ Handle one failed model-request attempt before the loop retries or closes its st Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestErrorAction](../core-data-structures/core.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:255`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:259`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -231,7 +231,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:212`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:216`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -252,7 +252,7 @@ Agent status changed (`idle` ⇄ `running`). A waking delivery enters `running` Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:173`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:177`](../../packages/core/agent/src/types.ts) ### `agent/turn-stopping` — serial @@ -281,7 +281,7 @@ The turn is about to close: the model owes no response (no live tool calls, no f Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:273`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:277`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index e79d5dac2f..ee870f9334 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/core-data-structures/core.md -core.md: dbd584f10b3daf873bc14210472efb6cd315717e -core.zh.md: 1fe1616a0c96abb4e8b91417cc4eae292416e42a +core.md: 8f413a7a064ad6f63e0caec31354869e51139020 +core.zh.md: d0f02f0cfc2cd30fc67aacf5b17d1daf324295c5 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index dbd584f10b..8f413a7a06 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -685,7 +685,11 @@ interface Agent { /** * Route identified input to an inbox boundary and optionally wake the driver. - * Waking input submitted after active cancellation is queued for the next turn. + * Waking input submitted after active cancellation is queued for the next + * turn and runs when the aborted activity converges to idle; a `disposed` + * cancel leaves it parked. A wake submitted while already idle always opens + * its turn boundary, even when its message is cleared before the driver + * claims ([cancel-convergence wake latch](../../../../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md)). * @param message - identified content and its producer provenance. * @param target - the preferred next-turn or next-step inbox boundary. * @param wakeup - whether delivery may wake the driver. diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 1fe1616a0c..d0f02f0cfc 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -693,7 +693,11 @@ interface Agent { /** * Route identified input to an inbox boundary and optionally wake the driver. - * Waking input submitted after active cancellation is queued for the next turn. + * Waking input submitted after active cancellation is queued for the next + * turn and runs when the aborted activity converges to idle; a `disposed` + * cancel leaves it parked. A wake submitted while already idle always opens + * its turn boundary, even when its message is cleared before the driver + * claims ([cancel-convergence wake latch](../../../../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md)). * @param message - identified content and its producer provenance. * @param target - the preferred next-turn or next-step inbox boundary. * @param wakeup - whether delivery may wake the driver. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 5c60be95e1..940d89eae0 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,18 +8,18 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:182`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:154`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:163`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry) | -| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/types.ts:192`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/types.ts:200`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/types.ts:181`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/types.ts:226`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:239`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:255`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:212`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:173`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`headless`](../packages/bundle/headless), [`jsonrpc`](../packages/ui/jsonrpc) | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:273`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:158`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:167`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:289`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry) | +| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/types.ts:196`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/types.ts:204`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/types.ts:185`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:243`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:259`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:216`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:177`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`headless`](../packages/bundle/headless), [`jsonrpc`](../packages/ui/jsonrpc) | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:277`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:172`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy` | | `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 533ebd3a97..3845c2cb1a 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -100,7 +100,7 @@ Sources: [`packages/core/session/src/types.ts:308`](../packages/core/session/src } ``` -Source: [`packages/core/agent/src/types.ts:296`](../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:300`](../packages/core/agent/src/types.ts) ### `approval/*` diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index b25a0f18f1..2f165e76e4 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/core/agent-loop/README.md -README.md: ec1948506bbaf7a3416c2031fb472a9b513b500f -README.zh.md: 5828da301b35c95719286fb942ac239769539b67 +README.md: 2fdc60086bebc924089b5b8bd12f4b4456b1ead5 +README.zh.md: df0ba44ec5b010666737c0568bc5eeb220ff0d96 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index ec1948506b..2fdc60086b 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -67,7 +67,7 @@ Every provider call that reaches a successful finish appends exactly one `assist After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate adapter-owned fields and materialize configured reasoning-effort and output-token defaults under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. The header records the effective config and which fields came from the adapter. Before the next waterfall, the loop removes those marked fields from the proposal so the current exact route rematerializes its own defaults; unmarked explicit settings persist across steps and route changes. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance applies the same provenance rule when resuming. -Plugin failure ends the current turn, not the loop. Final adapter selection, dispatch, and iteration failures arrive from `ctx.llm` as terminal error or aborted finishes and enter `agent/request-error`; middleware, result processing, tools, and other extension failures remain thrown and close directly. Recovery receives request coordinates, immutable provider facts, the immutable retry policy captured by the prepared adapter registration, and the turn signal; the policy is absent when middleware owns an unprepared route. A handling listener returns `{ kind: 'retry' }`; an unhandled failure is terminal. AgentLoop owns one cancellation signal for the current admission or turn. An effective `cancel(cause)` clears pending work unless `keepInbox` is set and cooperatively aborts that signal; idle cancellation is a no-op. Durable `turn/end` records `aborted` for `user` and `parent`, while disposal records `disposed`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. The cancellation cause changes reporting, not how result context finalized after cancellation is handled. Disposal waits for signal-ignoring work before registry removal. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract. +Plugin failure ends the current turn, not the loop. Final adapter selection, dispatch, and iteration failures arrive from `ctx.llm` as terminal error or aborted finishes and enter `agent/request-error`; middleware, result processing, tools, and other extension failures remain thrown and close directly. Recovery receives request coordinates, immutable provider facts, the immutable retry policy captured by the prepared adapter registration, and the turn signal; the policy is absent when middleware owns an unprepared route. A handling listener returns `{ kind: 'retry' }`; an unhandled failure is terminal. AgentLoop owns one cancellation signal for the current admission or turn. An effective `cancel(cause)` clears pending work unless `keepInbox` is set and cooperatively aborts that signal; idle cancellation is a no-op. Waking input that lands after the abort fires but before the activity converges to idle is latched (`wakeRequested`) and replayed at the driver's own convergence boundary, so it runs without a further waking send; a `disposed` cancel never latches, and a wake submitted while already idle always opens its turn boundary (status shows a transient `idle → running → idle` pair even when the message was cleared). Durable `turn/end` records `aborted` for `user` and `parent`, while disposal records `disposed`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. The cancellation cause changes reporting, not how result context finalized after cancellation is handled. Disposal waits for signal-ignoring work before registry removal. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) and the [cancel-convergence wake latch](../../../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md) own the lifecycle and race contract. Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, and retains their finalized result context without distinguishing the cancellation cause. An internal scheduler failure stops new dispatches, waits for already-started dispatches, and reaches the turn error boundary without fabricating tool results. diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 5828da301b..df0ba44ec5 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -67,7 +67,7 @@ interface Config { 在 `agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器持有的字段,并填入配置的推理(reasoning)强度和输出 token 默认值。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。请求 header 会记录生效配置以及哪些字段来自适配器。下一次 waterfall(瀑布式事件)前,循环会从提议中移除这些带标记字段,使当前精确路由重新填入自身默认值;未带标记的显式设置会跨步骤和路由变化保留。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例在恢复时会应用同一来源规则。 -插件失败会结束当前轮次,而不是结束循环。最终适配器选择、分发与迭代失败会由 `ctx.llm` 作为终止 error 或 aborted finish 返回,并进入 `agent/request-error`;middleware、结果处理、工具及其他扩展失败仍会抛出并直接关闭轮次。恢复逻辑会接收请求坐标、不可变的提供方事实、准备完成的适配器注册所捕获的不可变重试策略以及轮次信号;middleware 接管未准备路由时,该策略缺失。处理失败的监听器返回 `{ kind: 'retry' }`;未被处理的失败是终态。AgentLoop 为当前接纳或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。持久 `turn/end` 为 `user` 和 `parent` 记录 `aborted`,dispose(资源释放)则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call` 与 `ABORTED_BEFORE_DISPATCH` 结果对。取消原因只改变报告方式,不改变对取消后已定案结果上下文的处理。dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。 +插件失败会结束当前轮次,而不是结束循环。最终适配器选择、分发与迭代失败会由 `ctx.llm` 作为终止 error 或 aborted finish 返回,并进入 `agent/request-error`;middleware、结果处理、工具及其他扩展失败仍会抛出并直接关闭轮次。恢复逻辑会接收请求坐标、不可变的提供方事实、准备完成的适配器注册所捕获的不可变重试策略以及轮次信号;middleware 接管未准备路由时,该策略缺失。处理失败的监听器返回 `{ kind: 'retry' }`;未被处理的失败是终态。AgentLoop 为当前接纳或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。abort 触发后、活动收敛到空闲前到达的唤醒输入会被锁存(`wakeRequested`),并在 driver 自身的收敛边界重放,无需再发一条唤醒 send 即可执行;`disposed` 取消从不锁存,而 agent 已处于空闲时发送的唤醒总是打开自己的 turn 边界(即使消息已被清除,状态也会显示瞬态 `idle → running → idle` 对)。持久 `turn/end` 为 `user` 和 `parent` 记录 `aborted`,dispose(资源释放)则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call` 与 `ABORTED_BEFORE_DISPATCH` 结果对。取消原因只改变报告方式,不改变对取消后已定案结果上下文的处理。dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)与[取消收敛窗口唤醒锁存](../../../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md)规定生命周期与竞态契约。 在步骤内,独占调用形成屏障;并行安全调用使用有界滚动池,并在启动前重新分类。只有分发/主体会重叠。策略、持久结果和结果上下文仍保持模型顺序。中止会停止新调用,drain 已启动的结果,并保留其已定案的结果上下文,不区分取消原因。内部调度器故障会停止新的分发,等待已启动的分发,然后在不虚构工具结果的情况下到达轮次错误边界。 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index cfac8262c2..6701777a0f 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -43,7 +43,7 @@ type Phase = lastTurn: number wakeRequested: boolean } - | { kind: 'running'; abort: AbortController; turn: number; step: number } + | { kind: 'running'; abort: AbortController; turn: number; step: number; wakeRequested: boolean } type StepEndReason = Extract @@ -112,10 +112,12 @@ export class ReactLoopAgent implements Agent { send(message: UserMessage, target: InboxTarget, wakeup: boolean): void { // Waking input cannot join an aborted activity, so it starts the next turn. + // The classification is captured BEFORE the insertion: a reentrant cancel + // from a synchronous splice observer must not reclassify this wake. const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted const resolvedTarget = wakingAfterAbort ? 'next-turn' : target this.inbox.splice(resolvedTarget, Infinity, 0, [message]) - if (wakeup) this.wakeDriver() + if (wakeup) this.wakeDriver(wakingAfterAbort) } followup(input: UserMessage): void { @@ -133,7 +135,7 @@ export class ReactLoopAgent implements Agent { cancel(cause: AgentCancelCause, options: CancelOptions = {}): void { if (!options.keepInbox) { this.inbox.clear() - if (this.phase.kind === 'maintenance') this.phase.wakeRequested = false + if (this.phase.kind !== 'idle') this.phase.wakeRequested = false } if (this.phase.kind !== 'idle') this.phase.abort.abort(cause) } @@ -154,22 +156,44 @@ export class ReactLoopAgent implements Agent { return await task(maintenance.abort.signal) } finally { this.setPhase({ kind: 'idle', lastTurn: maintenance.lastTurn }) - if (maintenance.wakeRequested) this.wakeDriver() + if (maintenance.wakeRequested && this.inbox.hasPending) this.wakeDriver() done.resolve() } })() } - /** Start one driver, or remember its wake behind maintenance. */ - private wakeDriver(): void { - if (this.phase.kind === 'maintenance') { - if (!this.phase.abort.signal.aborted) this.phase.wakeRequested = true + /** + * Start one driver, or latch its wake behind maintenance or an aborted + * activity. A wake sent while idle always opens its turn boundary, even + * when its message is cleared before the driver claims; only a latched + * replay is suppressed when the queue no longer holds the wake. + * @param wakeAfterAbort - the send-time classification from {@link send}: + * the wake landed after the abort fired. Captured before the inbox + * insertion so a reentrant cancel cannot reclassify it. + */ + private wakeDriver(wakeAfterAbort = false): void { + if (this.phase.kind !== 'idle') { + // The current activity cannot deliver this wake: a maintenance task + // never reads the queue, and an aborted activity converges without + // restarting — both latch for the exiting activity to replay. A live + // driver claims queued work itself, so it needs no latch. A disposal + // cancel never latches: replaying would make `whenIdle()` wait on a + // full model turn over a session being torn down. + const reason = this.phase.abort.signal.reason as AgentCancelCause | undefined + if (reason?.kind !== 'disposed' && (this.phase.kind === 'maintenance' || wakeAfterAbort)) { + this.phase.wakeRequested = true + } return } - if (this.phase.kind !== 'idle') return const driver = Promise.withResolvers() this.activityDone = driver.promise - this.setPhase({ kind: 'running', abort: new AbortController(), turn: this.phase.lastTurn, step: 0 }) + this.setPhase({ + kind: 'running', + abort: new AbortController(), + turn: this.phase.lastTurn, + step: 0, + wakeRequested: false, + }) this.loopCtx.agents.withInitiator(this, () => this.kick()).then(driver.resolve, driver.reject) } @@ -196,7 +220,9 @@ export class ReactLoopAgent implements Agent { } finally { /* v8 ignore next -- kick owns a running phase until this driver boundary */ if (this.phase.kind === 'running') { - this.setPhase({ kind: 'idle', lastTurn: this.phase.turn }) + const { turn, wakeRequested } = this.phase + this.setPhase({ kind: 'idle', lastTurn: turn }) + if (wakeRequested && this.inbox.hasPending) this.wakeDriver() } } } @@ -302,6 +328,9 @@ export class ReactLoopAgent implements Agent { } if (!this.inbox.hasPending) return false phase.abort = new AbortController() + // The driver keeps running with a fresh controller: any latch set on the + // old one is stale, and the live driver claims the queue itself. + phase.wakeRequested = false phase.step = 0 return true } diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 5c0deed621..79bc65a105 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -126,6 +126,121 @@ describe('Agent.cancel()', () => { expect(adapter.requests).toHaveLength(3) }) + it('cancel({ keepInbox: true }) latches a waking send landing in the abort-to-idle window', async () => { + const adapter = new MockAdapter(['hang', textResponse('B reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('latch-window'), { provider: 'mock', model: 'mock' }) + + send(agent, 'active') + await new Promise(resolve => setTimeout(resolve, 30)) + + // The abort signal is set but the driver has not converged to idle yet: + // the waking send must be latched, not parked until another wake. + agent.cancel({ kind: 'user' }, { keepInbox: true }) + send(agent, 'B') + + await agent.whenIdle() + + expect(userTexts(agent)).toEqual(['active', 'B']) + expect(adapter.requests).toHaveLength(2) + expect(agent.inbox.nextTurn).toHaveLength(0) + expect(agent.session.events.filter(e => e.type === 'turn/end').map(e => + e.type === 'turn/end' ? e.data.reason : null)).toEqual([ + { kind: 'aborted', reason: { kind: 'user' } }, + { kind: 'completed' }, + ]) + }) + + it('cancel() without keepInbox clears a latched wake alongside the inbox', async () => { + const adapter = new MockAdapter(['hang', textResponse('C reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('latch-cleared'), { provider: 'mock', model: 'mock' }) + + send(agent, 'active') + await new Promise(resolve => setTimeout(resolve, 30)) + agent.cancel({ kind: 'user' }, { keepInbox: true }) + send(agent, 'B') // latched behind the aborted activity + agent.cancel({ kind: 'user' }) // drops the inbox and the latch with it + await agent.whenIdle() + + expect(userTexts(agent)).toEqual(['active']) + expect(agent.inbox.nextTurn).toHaveLength(0) + expect(adapter.requests).toHaveLength(1) + + send(agent, 'C') + await agent.whenIdle() + expect(userTexts(agent)).toEqual(['active', 'C']) + expect(adapter.requests).toHaveLength(2) + }) + + it('removing the latched wake before convergence suppresses the replay', async () => { + const adapter = new MockAdapter(['hang']) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('removed-latched-wake'), { provider: 'mock', model: 'mock' }) + + send(agent, 'active') + await new Promise(resolve => setTimeout(resolve, 30)) + + agent.cancel({ kind: 'user' }, { keepInbox: true }) + const steer = createUserMessage({ content: [{ type: 'text', text: 'steer me' }], source: { kind: 'user' } }) + agent.steer(steer) // latched behind the aborted activity + agent.inbox.remove(steer.id) // the wake is retracted before convergence + + await agent.whenIdle() + + expect(userTexts(agent)).toEqual(['active']) + expect(adapter.requests).toHaveLength(1) + expect(agent.inbox.nextTurn).toHaveLength(0) + expect(agent.status).toBe('idle') + // No replay with nothing to run: the latched message is gone, so no + // empty follow-up turn is recorded. + expect(agent.session.events.filter(e => e.type === 'turn/start')).toHaveLength(1) + }) + + it('latches a wake arriving deep into a slow abort convergence', async () => { + // The stream notices the abort only after 50ms, so the driver stays in + // the abort-to-idle window long after `cancel()` returned: the wake must + // be latched across the whole window, not just the same-tick case. + const adapter = new MockAdapter(['hang-slow', textResponse('B reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('slow-convergence'), { provider: 'mock', model: 'mock' }) + + send(agent, 'A') + await new Promise(resolve => setTimeout(resolve, 30)) + + agent.cancel({ kind: 'user' }, { keepInbox: true }) + await new Promise(resolve => setTimeout(resolve, 10)) + send(agent, 'B') + + await agent.whenIdle() + expect(userTexts(agent)).toEqual(['A', 'B']) + expect(adapter.requests).toHaveLength(2) + expect(agent.inbox.nextTurn).toHaveLength(0) + }) + + it('does not latch a wake landing after disposal begins', async () => { + const adapter = new MockAdapter(['hang-slow', textResponse('late reply')]) + const ctx = await harness(adapter) + const handle = await ctx.agents.create({ + sessionId: SessionId('dispose-window-wake'), + agentOptions: { provider: 'mock', model: 'mock' }, + }) + const agent = handle.agent + + send(agent, 'active') + await new Promise(resolve => setTimeout(resolve, 30)) + + // Dispose cancels with `{ kind: 'disposed' }`; a wake landing in the + // abort-to-idle window must not latch, so `whenIdle()` does not wait on + // a model turn over the session being torn down. + const disposal = handle.dispose() + setTimeout(() => { send(agent, 'late wake') }, 10) + await disposal + + expect(adapter.requests).toHaveLength(1) + expect(userTexts(agent)).toEqual(['active']) + }) + it('cancel after waking send closes its synchronously opened turn without a step', async () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) @@ -228,7 +343,7 @@ describe('Agent.cancel()', () => { expect(userTexts(agent)).toEqual(['first', 'later']) }) - it('replacement work queued after idle-listener cancellation waits for another wakeup', async () => { + it('replacement work queued after idle-listener cancellation replays at convergence', async () => { const adapter = new MockAdapter([ textResponse('first reply'), textResponse('replacement reply'), @@ -253,9 +368,11 @@ describe('Agent.cancel()', () => { if (replacementIdle === undefined) throw new Error('idle listener did not register replacement work') await replacementIdle - expect(adapter.requests).toHaveLength(1) - expect(userTexts(agent)).toEqual(['first']) - expect(agent.inbox.nextTurn).toHaveLength(1) + // The wake sent after the cancel fired is latched: the surviving + // replacement runs at convergence without a third message. + expect(adapter.requests).toHaveLength(2) + expect(userTexts(agent)).toEqual(['first', 'surviving replacement']) + expect(agent.inbox.nextTurn).toHaveLength(0) const idle = waitForIdle(ctx, agent) send(agent, 'wake it') @@ -479,7 +596,7 @@ describe('Agent.cancel()', () => { expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false) }) - it('a running-listener cancellation parks replacement work until another wakeup', async () => { + it('a running-listener cancellation replays replacement work at convergence', async () => { const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) @@ -497,17 +614,20 @@ describe('Agent.cancel()', () => { await idle dispose() - expect(userTexts(agent)).toEqual([]) - expect(agent.inbox.nextTurn).toHaveLength(1) + // B's wake was latched behind the cancelled driver: it runs on its own. + expect(userTexts(agent)).toEqual(['B']) + expect(agent.inbox.nextTurn).toHaveLength(0) + expect(adapter.requests).toHaveLength(1) const replacementIdle = waitForIdle(ctx, agent) send(agent, 'C') await replacementIdle expect(userTexts(agent)).toEqual(['B', 'C']) + expect(adapter.requests).toHaveLength(2) expect(agent.session.events.filter(event => event.type === 'turn/end')).toHaveLength(2) }) - it('a prompt queued during pre-step cancellation waits for another wakeup', async () => { + it('a prompt queued during pre-step cancellation replays at convergence', async () => { const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) @@ -518,13 +638,15 @@ describe('Agent.cancel()', () => { send(agent, 'B') await idle - expect(userTexts(agent)).toEqual([]) - expect(agent.inbox.nextTurn).toHaveLength(1) + expect(userTexts(agent)).toEqual(['B']) + expect(agent.inbox.nextTurn).toHaveLength(0) + expect(adapter.requests).toHaveLength(1) const replacementIdle = waitForIdle(ctx, agent) send(agent, 'C') await replacementIdle expect(userTexts(agent)).toEqual(['B', 'C']) + expect(adapter.requests).toHaveLength(2) expect(agent.session.events.filter(event => event.type === 'turn/end')).toHaveLength(3) }) @@ -556,7 +678,7 @@ describe('Agent.cancel()', () => { expect(flat).not.toContain('steer text') }) - it('parks replacement work queued synchronously by an abort observer', async () => { + it('replays replacement work queued synchronously by an abort observer', async () => { const adapter = new MockAdapter([ 'hang', textResponse('replacement reply'), @@ -586,13 +708,15 @@ describe('Agent.cancel()', () => { }), ]) - expect(adapter.requests).toHaveLength(1) - expect(userTexts(agent)).toEqual(['original']) - expect(agent.inbox.nextTurn).toHaveLength(1) + // The abort-observer wake was latched: replacement runs at convergence, + // so the original turn is followed by a completed replacement turn. + expect(adapter.requests).toHaveLength(2) + expect(userTexts(agent)).toEqual(['original', 'replacement']) + expect(agent.inbox.nextTurn).toHaveLength(0) const reasons = agent.session.events .filter(event => event.type === 'turn/end') .map(event => event.type === 'turn/end' ? event.data.reason : undefined) - expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }]) + expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }, { kind: 'completed' }]) const replacementIdle = waitForIdle(ctx, agent) send(agent, 'wake it') diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index c8d048ca47..1173607ae9 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -41,6 +41,14 @@ function send(agent: Agent, text: string) { agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })) } +/** All user-message texts recorded in the log (to assert what actually ran). */ +function userTexts(agent: Agent): string[] { + return agent.session.events + .filter(e => e.type === 'user/message') + .flatMap(e => e.type === 'user/message' ? e.data.content : []) + .flatMap(b => b.type === 'text' ? [b.text] : []) +} + describe('agent loop', () => { it.each([0, -1, 1.5, Number.NaN, Number.MAX_SAFE_INTEGER + 1])( 'rejects invalid AgentOptions.maxTokens %s before publication', @@ -70,7 +78,7 @@ describe('agent loop', () => { }) it('cancels queued wakeup work together with an active maintenance task', async () => { - const adapter = new MockAdapter([textResponse('unused')]) + const adapter = new MockAdapter([textResponse('park reply')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('cancel-maintenance-wakeup'), { provider: 'mock', @@ -87,15 +95,68 @@ describe('agent loop', () => { }) await started.promise - send(agent, 'discard this wakeup') - agent.cancel({ kind: 'user' }) - send(agent, 'park after cancellation') + send(agent, 'discard this wakeup') // latched behind the live maintenance task + agent.cancel({ kind: 'user' }) // drops the queue and the latch, aborts maintenance + send(agent, 'park after cancellation') // newer intent: re-latched, replays at convergence await expect(maintenance).rejects.toThrow('maintenance aborted') await agent.whenIdle() - expect(agent.inbox.nextTurn).toHaveLength(1) + + // The pre-cancel wakeup is gone; the post-cancel wake replays at convergence. + expect(userTexts(agent)).toEqual(['park after cancellation']) + expect(agent.inbox.nextTurn).toHaveLength(0) + expect(adapter.requests).toHaveLength(1) + }) + + it('replays a wake latched behind maintenance at convergence', async () => { + const adapter = new MockAdapter([textResponse('wake reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('maintenance-wake-replay'), { + provider: 'mock', + model: 'mock', + }) + const started = Promise.withResolvers() + const finish = Promise.withResolvers() + const maintenance = agent.runMaintenance(async () => { + started.resolve(undefined) + await finish.promise + }) + await started.promise + + send(agent, 'wake behind maintenance') + finish.resolve(undefined) + await maintenance + await agent.whenIdle() + + expect(userTexts(agent)).toEqual(['wake behind maintenance']) + expect(adapter.requests).toHaveLength(1) + }) + + it('suppresses the replay when a latched maintenance wake is removed', async () => { + const adapter = new MockAdapter([]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('maintenance-wake-removed'), { + provider: 'mock', + model: 'mock', + }) + const started = Promise.withResolvers() + const finish = Promise.withResolvers() + const maintenance = agent.runMaintenance(async () => { + started.resolve(undefined) + await finish.promise + }) + await started.promise + + const wake = createUserMessage({ content: [{ type: 'text', text: 'removed wake' }], source: { kind: 'user' } }) + agent.followup(wake) + agent.inbox.remove(wake.id) + finish.resolve(undefined) + await maintenance + await agent.whenIdle() + + expect(userTexts(agent)).toEqual([]) expect(adapter.requests).toEqual([]) - agent.cancel({ kind: 'user' }) + expect(agent.session.events.filter(e => e.type === 'turn/start')).toHaveLength(0) }) it('runs a simple turn: queued message → model → idle, with ordered events', async () => { diff --git a/packages/core/agent-loop/tests/mock-adapter.ts b/packages/core/agent-loop/tests/mock-adapter.ts index 6e592d9311..69dc78990c 100644 --- a/packages/core/agent-loop/tests/mock-adapter.ts +++ b/packages/core/agent-loop/tests/mock-adapter.ts @@ -58,14 +58,16 @@ export function toolCallResponse(rawCallId: string, name: string, args: object, /** * Mock adapter driven by a script: each model call consumes the next entry. * Records every request it receives for assertions. An entry may be a - * function to compute chunks from the request, or a 'hang' marker that - * streams one chunk then waits until aborted. + * function to compute chunks from the request, a 'hang' marker that + * streams one chunk then waits until aborted, or 'hang-slow' which takes + * 50ms to notice the abort — a stand-in for slow real-world teardown + * (LLM stream cancellation, tool unwinding). */ export class MockAdapter extends LlmAdapter { requests: GenerateOptions[] = [] constructor( - private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang')[], + private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang' | 'hang-slow')[], private readonly reasoning?: LlmModelReasoningInfo, private readonly defaultMaxTokens?: number, ) { @@ -98,6 +100,16 @@ export class MockAdapter extends LlmAdapter { }) return } + if (entry === 'hang-slow') { + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text: 'partial' } + await new Promise((_resolve, reject) => { + const fail = (): void => { reject(new Error('aborted')) } + if (options.signal?.aborted) { setTimeout(fail, 50); return } + options.signal?.addEventListener('abort', () => { setTimeout(fail, 50) }, { once: true }) + }) + return + } const chunks = typeof entry === 'function' ? entry(options) : entry for (const chunk of chunks) { if (options.signal?.aborted) throw new Error('aborted') diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index fae9267347..d8cb5c4114 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -104,7 +104,11 @@ export interface Agent { /** * Route identified input to an inbox boundary and optionally wake the driver. - * Waking input submitted after active cancellation is queued for the next turn. + * Waking input submitted after active cancellation is queued for the next + * turn and runs when the aborted activity converges to idle; a `disposed` + * cancel leaves it parked. A wake submitted while already idle always opens + * its turn boundary, even when its message is cleared before the driver + * claims ([cancel-convergence wake latch](../../../../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md)). * @param message - identified content and its producer provenance. * @param target - the preferred next-turn or next-step inbox boundary. * @param wakeup - whether delivery may wake the driver.