feat(core): make turn cancellation explicit
This commit is contained in:
55 files changed
+884
-383
No files matched your search
@@ -6,19 +6,19 @@ Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
The public `Agent` handle exposed two overlapping ways to stop in-flight work: `abort(reason?)` and `cancel(reason?)`. `abort()` killed only the in-flight step and left queued work alone; `cancel()` clears queued and steering work, aborts the running step, and handles the pre-step race. In production, ACP uses `cancel()` for `session/cancel`, while lifecycle owners tear down agents through `AgentHandle.dispose()`. No production caller needed bare `abort()`.
|
||||
The public `Agent` handle exposed two overlapping ways to stop in-flight work: step-only `abort()` and queue-aware `cancel()`. The former preserved queued input while the latter clears queued and steering work and aborts the active turn. In production, ACP uses `cancel()` for `session/cancel`, while lifecycle owners tear down agents through `AgentHandle.dispose()`. No production caller needs a bare step-only abort.
|
||||
|
||||
The `abort()`/`cancel()` distinction is real — `abort()` preserves queued prompts and steering while `cancel()` drops them — but no shipping code called the public `abort()` verb. The loop's own stop paths (`cancel()` and disposal) abort the current `AbortController` directly rather than routing through `Agent.abort()`. Most tests that called `abort()` interrupt an empty queue and switch to `cancel(reason)`; the steering re-delivery test that deliberately depends on queue preservation drives the in-flight `AbortController` directly, because `cancel()` would drop the queued steering it is trying to prove survives a step abort. The no-argument `abort()` default reason (`'aborted'`) is deleted with the verb rather than preserved by accident; `cancel()` keeps its own `'cancelled'` default.
|
||||
The behavioral distinction is real, but no shipping code needs the narrower operation. AgentLoop instead owns one private cancellation holder for the whole turn. `cancel(cause?)` carries a typed `user` or `parent` cause, defaults to `user`, and drops pending input; disposal remains a separate lifecycle interruption. The complete ownership and propagation contract lives in the [explicit turn cancellation RFC](../architecture/2026-07-16-explicit-turn-cancellation.md).
|
||||
|
||||
The extra surface area made the loop carry a public verb that is mostly a teardown internal: `abort()` had to be documented as distinct from queue-aware cancellation even though a UI cancellation almost always wants the broader operation.
|
||||
|
||||
## Decision
|
||||
|
||||
`cancel()` is the only public *stop* primitive on `Agent`. Lifecycle owners use `AgentHandle.dispose()` to stop and unregister an agent; non-owners use `cancel()` to abandon current and queued work. The implementation keeps a private abort controller, but it is not part of the plugin-facing `Agent` contract.
|
||||
`cancel()` is the only public *stop* primitive on `Agent`. Lifecycle owners use `AgentHandle.dispose()` to stop and unregister an agent; non-owners use `cancel()` to abandon current and queued work. The implementation keeps a private turn cancellation holder, but it is not part of the plugin-facing `Agent` contract.
|
||||
|
||||
`whenIdle()` is **retained** as the public quiescence-observation primitive (resolve once the agent settles out of `running`, resolve immediately when already idle, await the loop exit when disposed). It is not a stop verb; it is how a non-owner observes the stop *completing* without disposing the agent. Its live consumers are ACP and agent tests that await settlement through this public seam (`packages/ui/acp/tests`, `packages/core/agent-loop/tests`); the production ACP bridge owns its agents and tears them down through `AgentHandle.dispose()`, so `packages/ui/acp/src` itself has no `whenIdle()` call.
|
||||
|
||||
Public `abort()` is deleted, with the tests that exercised it as standalone API and the docs that described step-only abort as an embedding feature. Empty-queue abort tests migrated to `cancel(reason)` where they still prove cancellation behavior; tests whose subject is the loop's internal `AbortController` drive that controller directly via an in-package typed cast to the private field; tests that only pinned the removed no-arg `abort()` default went with the method. The disposer remains async and still waits for the loop to stop.
|
||||
Public `abort()` is absent, and the disposer remains async and waits for the loop to stop. Tests exercise cancellation through the public typed cause and explicit signal seams rather than reaching into the holder.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
Reference in New Issue
Block a user