Преглед изворни кода

Merge branch 'worktree-simplify-agent-stop' into worktree-simplify-trace-events

Tianyi Cui пре 3 месеци
родитељ
комит
f09a88ecee

+ 1 - 1
docs/architecture.md

@@ -113,7 +113,7 @@ Tool schemas are deliberately **part of the assembly**: "what the model is told
 - `steer(content)` — mid-turn injection, drained **between steps**; behaves like `send` when idle
 - `inject(content)` — in-session context (`context/message` event); the next request sees it (Claude Code attachment / system-reminder analog). An inject made while the agent is *running* joins the open turn; an inject while *idle* is wrapped in a one-shot turn (`turn/start{trigger:injection}` → `context/message` → `turn/end`) so every event stays turn-enclosed (see [the turn-enclosure invariant](rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)).
 - `cancel(reason)` — the single public stop primitive: clears queued + steering work, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs and cannot be batched into the cancelled turn. A UI/ACP `session/cancel` maps to it.
-- `whenIdle()` — resolves once the agent reaches quiescence after settling out of `running` (resolves immediately when already idle; awaits the loop exit when disposed). The teardown signal: `cancel()` then `await whenIdle()` guarantees the in-flight turn has fully stopped. Observes the transition without disposing the agent.
+- `whenIdle()` — resolves once the agent reaches quiescence after settling out of `running` (resolves immediately when already idle; awaits the loop exit when disposed). A non-owner's quiescence-observation hook: it lets a consumer await the current work settling **without** disposing the agent. It is NOT teardown — it does not stop queued work, unregister the agent, or detach the session; a lifecycle owner tears an agent down with `await AgentHandle.dispose()` (which stops the loop, awaits its exit, and unregisters).
 - `session`, `status`, `options`
 
 **TODO(sub-agents)**: `spawn`/`fork` land on `AgentLoop.create()` — fork seeds the child Session with the parent's event log, spawn starts fresh; children are ordinary `Agent` handles so `steer()` and event subscription work uniformly. Inter-agent channels beyond these primitives are deliberately deferred.

+ 2 - 2
docs/cookbook/extension-cookbook.md

@@ -56,7 +56,7 @@ export function apply(ctx: Context) {
 
 ## A client-driver plugin (external protocol bridge)
 
-A *client driver* is a UI plugin whose "user" is another program speaking a wire protocol rather than a human at a terminal. It owns the process's stdio (so it must run with **no stdout logger** — every non-protocol byte corrupts the stream), creates/resumes agents on demand through the `dsh-agent` factory seam, translates harness events (`session/event`, `agent/*`) into outbound protocol messages, and translates inbound requests back into `agent.send()` / `agent.cancel()`. Two harness-specific contracts make it correct: resolve each request exactly once off a settle signal (the turn can end without its `agent/turn-end` event firing — fall back through the logged `turn/end` record), and on disposal reach quiescence (handle disposal aborts in-flight work then `await`s `agent.whenIdle()`), not just request it.
+A *client driver* is a UI plugin whose "user" is another program speaking a wire protocol rather than a human at a terminal. It owns the process's stdio (so it must run with **no stdout logger** — every non-protocol byte corrupts the stream), creates/resumes agents on demand through the `dsh-agent` factory seam, translates harness events (`session/event`, `agent/*`) into outbound protocol messages, and translates inbound requests back into `agent.send()` / `agent.cancel()`. Two harness-specific contracts make it correct: resolve each request exactly once off a settle signal (the turn can end without its `agent/turn-end` event firing — fall back through the logged `turn/end` record), and tear each agent down through its `AgentHandle.dispose()` (which stops the loop, `await`s its exit, and unregisters), not just `cancel()` — disposal must *reach* quiescence, not merely request it.
 
 `packages/ui/acp` is the worked example: it bridges the agent to the Agent Client Protocol (JSON-RPC over stdio) so Zed and other ACP editors can drive it. See its README for the full method surface and the deferred-permission-gate note.
 
@@ -77,7 +77,7 @@ export function apply(ctx: Context) {
     }
   })
   // Inbound "prompt": create/resume an agent and feed it; settle on turn end.
-  // Disposal awaits quiescence: handle disposal aborts, then await agent.whenIdle().
+  // Teardown reaches quiescence via AgentHandle.dispose() (stop + await exit).
 }
 ```
 

+ 14 - 14
docs/cordis-catalog/events-and-services.md

@@ -25,7 +25,7 @@ An agent was registered in the AgentRegistry and is ready to receive messages.
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:137`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:136`](../../packages/core/agent/src/types.ts)
 
 #### `agent/disposed` — emit
 
@@ -37,7 +37,7 @@ An agent was disposed and removed from the registry; its fiber and any in-flight
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:143`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:142`](../../packages/core/agent/src/types.ts)
 
 #### `agent/error` — emit
 
@@ -49,7 +49,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:220`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:219`](../../packages/core/agent/src/types.ts)
 
 #### `agent/queued` — emit
 
@@ -61,7 +61,7 @@ A message entered the agent's inbox (queued or steering). `source` is the resolv
 
 Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:156`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:155`](../../packages/core/agent/src/types.ts)
 
 #### `agent/request` — waterfall
 
@@ -73,7 +73,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo
 
 Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:189`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:188`](../../packages/core/agent/src/types.ts)
 
 #### `agent/status` — emit
 
@@ -85,7 +85,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:150`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:149`](../../packages/core/agent/src/types.ts)
 
 #### `agent/steering` — emit
 
@@ -97,7 +97,7 @@ Steering content was injected into a running turn.
 
 Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:214`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:213`](../../packages/core/agent/src/types.ts)
 
 #### `agent/step-end` — emit
 
@@ -109,7 +109,7 @@ A step ended.
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:180`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:179`](../../packages/core/agent/src/types.ts)
 
 #### `agent/step-result` — waterfall
 
@@ -121,7 +121,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va
 
 Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:195`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:194`](../../packages/core/agent/src/types.ts)
 
 #### `agent/step-start` — emit
 
@@ -133,7 +133,7 @@ A step (one model call plus its tool dispatch) began. `step` is 1-based within t
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:175`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:174`](../../packages/core/agent/src/types.ts)
 
 #### `agent/stream-chunk` — emit
 
@@ -145,7 +145,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed).
 
 Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
 
-Source: [`packages/core/agent/src/types.ts:209`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:208`](../../packages/core/agent/src/types.ts)
 
 #### `agent/turn-continuation` — waterfall
 
@@ -157,7 +157,7 @@ Waterfall: override the turn-continuation decision. The default (computed by the
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:202`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:201`](../../packages/core/agent/src/types.ts)
 
 #### `agent/turn-end` — emit
 
@@ -169,7 +169,7 @@ A turn ended. `reason` distinguishes a clean stop from a truncated or aborted on
 
 Types: [Agent](../core-data-structures/core.md) · [TurnEndReason](../core-data-structures/session.md)
 
-Source: [`packages/core/agent/src/types.ts:169`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:168`](../../packages/core/agent/src/types.ts)
 
 #### `agent/turn-start` — emit
 
@@ -181,7 +181,7 @@ A turn began. `turn` is the 1-based turn number within the session.
 
 Types: [Agent](../core-data-structures/core.md)
 
-Source: [`packages/core/agent/src/types.ts:163`](../../packages/core/agent/src/types.ts)
+Source: [`packages/core/agent/src/types.ts:162`](../../packages/core/agent/src/types.ts)
 
 ### `llm/*`
 

+ 9 - 10
docs/core-data-structures/core.md

@@ -253,12 +253,15 @@ interface Agent {
 
   /**
    * Resolve once the agent has reached quiescence after settling out of
-   * `running`, or immediately if it is already idle with no queued work. The
-   * quiescence signal a teardown awaits: a lifecycle owner disposes the agent
-   * through its `AgentHandle` (which aborts in-flight work then awaits this), so
-   * the caller proceeds only after queued/running work has fully stopped (a
-   * closing ACP connection, a disposing UI plugin) rather than returning while
-   * the driver is still streaming or about to start a queued turn.
+   * `running`, or immediately if it is already idle with no queued work. A
+   * non-owner's quiescence-observation hook: a consumer that does NOT own the
+   * agent's lifecycle awaits this to proceed only after queued/running work has
+   * fully stopped, rather than returning while the driver is still streaming or
+   * about to start a queued turn — without itself tearing the agent down. (A
+   * lifecycle OWNER does not need it: `AgentHandle.dispose()` already awaits the
+   * loop-exit promise directly as part of stopping and unregistering. So this is
+   * for a non-owning observer — e.g. a test awaiting a turn to settle, or a
+   * monitor — that wants the settle signal but must not dispose the agent.)
    *
    * "Quiescence", not merely "status changed": a disposed agent emits
    * `agent/status('disposed')` from inside its disposer, BEFORE the driver loop
@@ -266,10 +269,6 @@ interface Agent {
    * to actually exit (the implementation chains the loop-exit promise), not just
    * observe the status flip. A mid-step disposal that never reaches `idle` still
    * unblocks the await this way.
-   *
-   * Distinct from disposal: `whenIdle()` observes the transition WITHOUT tearing
-   * the agent down. A consumer that owns the agent's lifecycle disposes it
-   * separately.
    */
   whenIdle(): Promise<void>
 

+ 1 - 1
docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md

@@ -16,7 +16,7 @@ The extra surface area made the loop carry a public verb that is mostly a teardo
 
 Keep `cancel()` as 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.
 
-`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, and it has live consumers (the ACP bridge's settle points).
+`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.
 
 Delete public `abort()`, the tests that exercise it as standalone API, and the docs that describe step-only abort as an embedding feature. Empty-queue abort tests migrate to `cancel(reason)` where they still prove cancellation behavior; tests whose subject is the loop's internal `AbortController` behavior drive that controller directly via an in-package typed cast to the private field; tests that only pin the removed no-arg `abort()` default go away with the method. The disposer remains async and still waits for the loop to stop.
 

+ 2 - 2
docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md

@@ -37,7 +37,7 @@ The mapping between ACP and existing harness seams — each row names the seam a
 
 The permission gate is the first real consumer of the `tools/execute` veto seam (the documented "single veto/sandbox/permission seam" plus the deferred "Permission system" TODO in [docs/architecture.md](../../../architecture.md)). It is a single global listener registered with `prepend: true` so it runs before any other tool wrapper. `ToolExecution.agent` is optional and the `Agent` interface carries no origin marker, so the bridge tracks ownership itself: it records each agent it creates in a `WeakMap<Agent, sessionId>` and the gate no-ops (calls `next()` immediately) for any `exec.agent` it does not own — non-ACP agents and the no-agent case pass straight through. For an owned agent it resolves the session, issues `session/request_permission`, and stores the pending resolver on that session's record so the outcome — or a `session/cancel`/connection-close — settles it exactly once.
 
-Lifecycle and disposal: the connection, listeners, and in-flight permission promises register via `ctx.effect`/`ctx.on`; teardown is async and awaits quiescence — close the connection, settle/reject pending permissions, `agent.cancel()`, and wait for the agent to settle. The disposal-settle signal must come from the `dsh-agent` interface, not the loop: `agent.done` exists only on the concrete `ReactLoopAgent`, so the bridge instead observes `agent/status` reaching `idle`/`disposed` (or the RFC lifts a quiescence promise onto the `Agent` interface). Every listener contains its `send()` exceptions (log, never reject the turn) because stream chunks are emitted inside the model step, so a throwing listener would corrupt the turn.
+Lifecycle and disposal: the connection, listeners, and in-flight permission promises register via `ctx.effect`/`ctx.on`; teardown is async and must *reach* quiescence, not just request it — close the connection, settle/reject pending permissions, and dispose each owned agent through its `AgentHandle.dispose()` (which stops the loop, `await`s its exit, and unregisters). Owner teardown goes through that handle seam, not the loop's concrete `agent.done` (which exists only on `ReactLoopAgent`); a non-owner that merely wants to *observe* the current work settling without tearing the agent down awaits the interface-level `agent.whenIdle()`. Every listener contains its `send()` exceptions (log, never reject the turn) because stream chunks are emitted inside the model step, so a throwing listener would corrupt the turn.
 
 **Dependency note (architecture rule).** [docs/architecture.md](../../../architecture.md) states "plugins depend on interface packages, never on `dsh-agent-loop`." Creating and resuming agents is currently only on the concrete `AgentLoop` (`ctx.agentLoop`), so this RFC proposes adding an **abstract create/resume factory** to the `dsh-agent` interface (registry-level `create({ sessionId, meta })` / `resume(...)`), implemented by the loop, so `dsh-acp` injects only `agents` (the interface) and the dependency rule holds. The alternative — injecting the concrete `agentLoop` and recording a documented exception in the architecture doc — is explicitly the non-preferred fallback.
 
@@ -67,7 +67,7 @@ New third-party runtime dependency plus protocol drift: `@agentclientprotocol/sd
 
 Turn-settle and prompt-correlation hazards: honor "queued messages batch into one turn" and "`send()` does not synchronously flip to running" (see `stdio-chat.ts` and the defensive-patterns section of [docs/architecture.md](../../../architecture.md)); gate resolution on an observed running→idle transition and handle the empty-prompt / no-work branch so an RPC can't hang.
 
-Permission-await and disposal hangs: a pending `request_permission` whose connection closes or whose turn aborts must settle exactly once; disposal must reach quiescence (observe the interface-level settle signal — `agent/status` reaching `idle`/`disposed`, since `agent.done` is `ReactLoopAgent`-only), not orphan awaits on a closed pipe.
+Permission-await and disposal hangs: a pending `request_permission` whose connection closes or whose turn aborts must settle exactly once; disposal must reach quiescence — tear each owned agent down through `AgentHandle.dispose()` (which stops the loop and awaits its exit), rather than orphaning awaits on a closed pipe.
 
 The 100% per-file coverage gate (repo policy) makes a branch-heavy protocol bridge real work. Accepted deliberately, surfaced so it isn't a surprise at PR time.
 

+ 4 - 2
packages/core/agent-loop/src/agent.ts

@@ -228,8 +228,10 @@ export class ReactLoopAgent implements Agent {
    * internal waiter (see {@link idleWaiters}) released on the next
    * running→idle/disposed transition, resolving on `idle` directly (the turn
    * fully ended) or chaining {@link done} on `disposed` (wait for the loop to
-   * actually exit). Implements the {@link Agent.whenIdle} contract used by
-   * teardown (handle disposal aborts in-flight work, then awaits `whenIdle()`).
+   * actually exit). Implements the {@link Agent.whenIdle} contract: a non-owner
+   * quiescence-observation hook, distinct from teardown (a lifecycle owner stops
+   * and unregisters via `AgentHandle.dispose()`, which awaits {@link done}
+   * directly, not through this).
    */
   whenIdle(): Promise<void> {
     if (this._status === 'disposed') return this.done

+ 1 - 1
packages/core/agent/README.md

@@ -57,7 +57,7 @@ The handle every plugin programs against:
 - `agent.steer(content, options?)` — steer a running turn (inject between steps); behaves like `send` when idle
 - `agent.inject(content, options?)` — inject in-session context (context/message event); the next request sees it. Does not run the model. While a turn is open it joins that turn; while idle it is wrapped in a one-shot `injection` turn so every event stays turn-enclosed ([the turn-enclosure invariant](../../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md))
 - `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op.
-- `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit), the signal a teardown awaits (a lifecycle owner disposes the handle, which aborts in-flight work then awaits this). Observes the transition without disposing the agent.
+- `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly.
 - `agent.session`, `agent.status`, `agent.options`, `agent.id`
 
 ### Extension points

+ 9 - 10
packages/core/agent/src/types.ts

@@ -99,12 +99,15 @@ export interface Agent {
 
   /**
    * Resolve once the agent has reached quiescence after settling out of
-   * `running`, or immediately if it is already idle with no queued work. The
-   * quiescence signal a teardown awaits: a lifecycle owner disposes the agent
-   * through its `AgentHandle` (which aborts in-flight work then awaits this), so
-   * the caller proceeds only after queued/running work has fully stopped (a
-   * closing ACP connection, a disposing UI plugin) rather than returning while
-   * the driver is still streaming or about to start a queued turn.
+   * `running`, or immediately if it is already idle with no queued work. A
+   * non-owner's quiescence-observation hook: a consumer that does NOT own the
+   * agent's lifecycle awaits this to proceed only after queued/running work has
+   * fully stopped, rather than returning while the driver is still streaming or
+   * about to start a queued turn — without itself tearing the agent down. (A
+   * lifecycle OWNER does not need it: `AgentHandle.dispose()` already awaits the
+   * loop-exit promise directly as part of stopping and unregistering. So this is
+   * for a non-owning observer — e.g. a test awaiting a turn to settle, or a
+   * monitor — that wants the settle signal but must not dispose the agent.)
    *
    * "Quiescence", not merely "status changed": a disposed agent emits
    * `agent/status('disposed')` from inside its disposer, BEFORE the driver loop
@@ -112,10 +115,6 @@ export interface Agent {
    * to actually exit (the implementation chains the loop-exit promise), not just
    * observe the status flip. A mid-step disposal that never reaches `idle` still
    * unblocks the await this way.
-   *
-   * Distinct from disposal: `whenIdle()` observes the transition WITHOUT tearing
-   * the agent down. A consumer that owns the agent's lifecycle disposes it
-   * separately.
    */
   whenIdle(): Promise<void>