fix(session): contain post-commit observers
This commit is contained in:
@@ -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 (settle from the durable `turn/end` session event — the boundary is a session event, not an `agent/*` mirror — with `agent/status` as the fallback if a peer listener starved yours), 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.
|
||||
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 from the durable `turn/end` session event, using `agent/status` only as defensive reconciliation against the canonical log, 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 permission-prompt answerer it registers on the approval seam.
|
||||
|
||||
|
||||
@@ -265,7 +265,7 @@ Source: [`packages/core/session/src/index.ts:64`](../../packages/core/session/sr
|
||||
|
||||
### `session/event` — emit
|
||||
|
||||
An event was appended to a session log (sync, fire-and-forget). This is the per-append feed a UI or invariant plugin tails. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the session's owner scope, captured when the session was ENTERED (an agent's session is entered through `agent.ctx`, so its events dispatch in that agent's scope; a bare `sessions.create()` from a plain plugin dispatches subject-less). A listener registered through `agent.ctx` hears only that agent's sessions; a plain plugin listener hears every session.
|
||||
An event was appended to a session log (sync, fire-and-forget). This is the per-append feed a UI or invariant plugin tails. The log push is the commit point; synchronous throws and returned-promise rejections from observers are logged and contained per listener, so they cannot make a committed append appear to fail or starve later listeners. The exact callback list and Cordis internal-dispatch checks resolve before the push; callbacks themselves run only after it. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the session's owner scope, captured when the session was ENTERED (an agent's session is entered through `agent.ctx`, so its events dispatch in that agent's scope; a bare `sessions.create()` from a plain plugin dispatches subject-less). A listener registered through `agent.ctx` hears only that agent's sessions; a plain plugin listener hears every session.
|
||||
|
||||
```ts cordis-catalog
|
||||
'session/event'(this: Scoped<Session>, session: Session, event: SessionEvent): void
|
||||
@@ -273,7 +273,7 @@ An event was appended to a session log (sync, fire-and-forget). This is the per-
|
||||
|
||||
Types: [SessionEvent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:78`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:83`](../../packages/core/session/src/index.ts)
|
||||
|
||||
### `session/flush` — parallel
|
||||
|
||||
@@ -283,7 +283,7 @@ Awaited durability checkpoint. The agent loop awaits `ctx.sessions.flush(session
|
||||
'session/flush'(this: Scoped<Session>, session: Session): Promise<void> | void
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:96`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:101`](../../packages/core/session/src/index.ts)
|
||||
|
||||
## `skill/*`
|
||||
|
||||
|
||||
@@ -224,7 +224,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId):
|
||||
|
||||
Types: [SessionRegistrationReservation](../core-data-structures/session.md)
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:667`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:761`](../../packages/core/session/src/index.ts)
|
||||
|
||||
## `ctx.skills` — `SkillService`
|
||||
|
||||
|
||||
@@ -27,8 +27,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:39`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:52`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | - |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:78`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:96`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| `skill/provider-added` | `emit` | [`packages/skill/skill/src/index.ts:132`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - |
|
||||
| `skill/provider-removed` | `emit` | [`packages/skill/skill/src/index.ts:138`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:134`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
|
||||
|
||||
@@ -18,7 +18,7 @@ A new `cancel()` verb on the `Agent` interface — the single public stop primit
|
||||
|
||||
`ctx.agents.create`/`resume` (and the `AgentFactory` interface) return `AgentHandle = { agent: Agent; dispose(): Promise<void> }`. The disposer is a **consumer capability** — a registry observer holding only the bare `Agent` cannot tear it down. The caller fiber and registered factory provider are structural co-owners: caller unload enforces structured ownership, while provider unload must stop old instances whose scoped dependency surface resolves through that provider. All three paths reach the same memoized teardown: stop the loop, `await` its exit (true quiescence, not just the `disposed` status flip), unregister it, remove its session from the store, unwind its scope, and only then release both public IDs. Config-created agents are already owned by the `AgentLoop` fiber (the handle is discarded). ACP holds each session's disposer in its `SessionRecord` and runs it on disconnect/teardown, so a bare client disconnect leaves no registered agent and no session-store entry — even when `session/load` races teardown (the just-resumed handle is disposed before the closed-guard throw).
|
||||
|
||||
**Teardown ORDER is load-bearing for durability**, and the implementation folds the session lifecycle into the agent's SINGLE composite cordis effect (`SessionStore.prepare`/`enter`/`announce`, replacing a sibling-effect split). A fiber unload disposes sibling effects concurrently (`Promise.all`), which would race detaching the session store's private append observer against the loop's closing `session/flush` and drop the closing `turn/end`; inside one effect the disposers run as an ordered LIFO chain (loop stopped + `await agent.done` BEFORE the session detaches), so the loop's final flush is captured on BOTH the handle's `dispose()` and a fiber unload. The contained `agent/disposed` and `session/disposed` notifications cannot reject the chain or skip later teardown.
|
||||
**Teardown ORDER is load-bearing for durability**, and the implementation folds the session lifecycle into the agent's SINGLE composite cordis effect (`SessionStore.prepare`/`enter`/`announce`, replacing a sibling-effect split). A fiber unload disposes sibling effects concurrently (`Promise.all`), which would race removing the session store's append publication hooks against the loop's closing `session/flush` and drop the closing `turn/end`; inside one effect the disposers run as an ordered LIFO chain (loop stopped + `await agent.done` BEFORE the session detaches), so the loop's final flush is captured on BOTH the handle's `dispose()` and a fiber unload. The contained `agent/disposed` and `session/disposed` notifications cannot reject the chain or skip later teardown.
|
||||
|
||||
### 3. Bash owner token in the seam
|
||||
|
||||
@@ -40,7 +40,7 @@ The bash owner-token comparison relies on `session.header.id` being unique among
|
||||
## Alternatives considered
|
||||
|
||||
- **A public `BashTask.owner` field** instead of the `BashExecutor.ownerOf(id)` seam — rejected: one read path, no redundant API.
|
||||
- **Sibling cordis effects for the agent's session lifecycle** — rejected: a fiber unload disposes sibling effects concurrently (`Promise.all`), racing the store-owned append observer's detach against the loop's closing `session/flush`; the single composite effect's ordered LIFO chain is what captures the closing `turn/end` on both disposal paths.
|
||||
- **Sibling cordis effects for the agent's session lifecycle** — rejected: a fiber unload disposes sibling effects concurrently (`Promise.all`), racing removal of the store-owned append publication hooks against the loop's closing `session/flush`; the single composite effect's ordered LIFO chain is what captures the closing `turn/end` on both disposal paths.
|
||||
- **A separate step-only `abort()` beside `cancel()`** — shipped originally, then removed as unused; `cancel()` is the single public stop primitive ([the public-stop-surface RFC](../simplification/2026-06-20-public-agent-stop-surface.md)).
|
||||
|
||||
## Consequences
|
||||
|
||||
@@ -28,9 +28,9 @@ This is the foundational change in a stack that adds a Hooks subsystem; it estab
|
||||
|
||||
## Consequences
|
||||
|
||||
- The loop no longer emits any boundary mirror; `closeStep` appends `step/end` only and `closeTurn` appends `turn/end` only. A throwing `step/end`/`turn/end` session-event listener is the surviving boundary-listener failure path (contained inside `closeStep`/`closeTurn` — `Session.append` pushes the event before notifying listeners, so the boundary is durable and the turn closes balanced regardless).
|
||||
- The loop no longer emits any boundary mirror; `closeStep` appends `step/end` only and `closeTurn` appends `turn/end` only. `Session.append` owns post-commit observer containment, so a throwing boundary observer cannot change the turn outcome or starve later consumers; an acceptance or internal validation failure still escapes before the boundary enters the log.
|
||||
- Tests that observed boundaries via the removed emits now observe the durable `turn/start`/`turn/end`/`step/start`/`step/end` session events — the behavior they pin (boundary ordering, step counting) is unchanged; only the feed they read moved to the canonical one. The tests that exercised a *throwing turn-boundary emit listener* were deleted, because that code path no longer exists (there is no emit to throw from). Per [AGENTS.md "tests document behavior, not golden truth"](../../../../AGENTS.md), the behavior and its test moved (or died) together.
|
||||
- The loop marks the step open (`stepOpen = true`) BEFORE appending `step/start`, because `Session.append` pushes the event to the log before notifying `session/event` listeners (validation throws happen earlier, before the push — see [the session append contract](../../../core-data-structures/session.md)). So a throwing `step/start` session-event listener runs with the step already open and the event already in the log: the loop's outer catch then calls `closeStep()`, which appends the balancing `step/end`, and the turn closes balanced with an error (`turn/start → step/start → step/end → turn/end` — verified by the invariants oracle in the regression test). Closing the open step is owed precisely because the marker is set first.
|
||||
- The loop marks the step open (`stepOpen = true`) only after `append('step/start')` returns. Internal dispatch validation runs before the log push and may reject without opening a step; post-commit `session/event` observer failures are contained inside `Session.append`. The marker therefore represents exactly the committed boundary that owes a later `step/end`.
|
||||
- The full realization of this is [the simplification RFC "Stop mirroring durable boundaries as agent events"](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md): all four boundary mirrors are removed and every consumer reads boundaries off `session/event`. `agent/steering` (not a boundary mirror) stayed outside that RFC's scope and was removed by its own follow-up, [Remove the `agent/steering` mirror emit](../simplification/2026-07-04-remove-agent-steering-mirror.md) — it mirrored the durable `steering/message`.
|
||||
- The cordis events catalog (`docs/cordis-catalog/events.md`) is regenerated to drop the mirror events.
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ Prefix-cache stability is corollary #1, not the headline: an append-only log pro
|
||||
|
||||
**The loop, transmission-stateless.** Per step: render assembly (every step — value comparison needs no change-signal discipline, and a section that varies per step surfaces as a *logged* header event per step instead of a silent bust) → on the instance's FIRST step only, the `agent/session-prefix` waterfall — request-ONLY messages fronting the entire derived history (a frozen empty seed, contributions returned as an extension of `next()`; the home for session-stable openers that must NOT become history — a skills catalog, an AGENTS.md digest), deep-frozen and cached on the instance so reuse is structural and the prefix cannot drift mid-session — → `agent/pre-step`, carrying the composed prefix (compaction's surface mutations land before derivation, and its pressure gate counts the prefix this instance will actually send — never a previous instance's logged one, which could under-gate a resumed/forked instance whose contributor grew) → **messages snapshot, then `step/start` appended as the next operation in the same synchronous frame** → seed the call config (first request of the instance: from `AgentOptions`, so explicit options always beat the logged baseline — fork model-overrides and resume reconfiguration stay correct; afterwards: from the folded header) → the `agent/request` waterfall, re-typed `(agent, turn, step, config: LlmCallConfig, next) → LlmCallConfig` — a frozen seed and a returned replacement are ALL a listener shapes; durable content flows through the log channels (`inject()`, steering, prompt-submit `additionalContext`, sections via `system-prompt/assemble`) — → the header event the request owes the log, carrying the prefix as `messagePrefix` (no session event carries it, so the header is its only durable record; resume = a new instance = a recompose, anchored by its `'resume'` snapshot) → build `GenerateOptions` from `messagePrefix + snapshot` + header, deep-freeze (`deepFreeze` exempts the `AbortSignal`, the one live control channel — freezing one breaks `AbortController.abort()`), dispatch. The loop's per-instance bookkeeping is one boolean plus the cached prefix: whether this instance has logged its anchoring snapshot, and what it composed.
|
||||
|
||||
**The reconstruction boundary is `step/start`, unconditionally.** A step's messages are the derivation over `events[0..stepStartSeq)`. Because the snapshot precedes the `step/start` append in the same synchronous frame, nothing can enter this request past the boundary: an `agent.inject()` from an `agent/request` listener (or any concurrent task, or a `session/event` listener firing on `step/start` itself) lands in the log after the boundary and joins the NEXT request. For waterfall-window appends this matches the prior loop (it also derived before its waterfall); for a synchronous `step/start` listener it is a deliberate change — such a listener could previously reach the current request — and `agent/pre-step` is the sanctioned seam for content that must affect the CURRENT request. A step's header for reconstruction is the fold after its own `request/header*` event (which sits between its `step/start` and first response event) or the fold carried forward.
|
||||
**The reconstruction boundary is `step/start`, unconditionally.** A step's messages are the derivation over `events[0..stepStartSeq)`. Because the snapshot precedes the `step/start` append in the same synchronous frame, an `agent.inject()` from an `agent/request` listener or any concurrent task lands after the boundary and joins the NEXT request. `session/event` is observe-only during publication: a reentrant append is rejected until the current callback list drains, preventing nested event delivery from overtaking the event being observed. `agent/pre-step` is the sanctioned seam for content that must affect the CURRENT request. A step's header for reconstruction is the fold after its own `request/header*` event (which sits between its `step/start` and first response event) or the fold carried forward.
|
||||
|
||||
**Enforcement.** Dev-mode ([dsh-invariants](../../../../packages/support/invariants/src/index.ts)), on `llm/stream`: a frozen request with a live `sessionId` — the loop-built marker; hand-built one-shots are unfrozen and skipped — must carry messages deep-equal to the folded header's `messagePrefix` followed by the boundary derivation — the derivation rebuilt through a FRESH `Session` over `events[0..stepStartSeq)` so the live cache cannot vouch for itself — and header fields equal to `foldRequestHeader` over the log. There is no divergence allowance and nothing to allow: no seam can put unlogged content into a request — the `agent/session-prefix` seam's product enters only because the header event records it first. `prepend: true` only defends against the replay adapter's short-circuit (an append-registered listener); two prepended listeners have no defined mutual order in cordis, so correctness rests on the seq-bounded fold, never on listener timing. Measurement stays lean: the with-key e2e ([request-cache.e2e.ts](../../../../packages/core/agent-loop/tests/request-cache.e2e.ts)) proves `usage.cacheReadTokens > 0` on every request after the first against the live API, and per-step usage in the log is the production observable — a header event or compaction shows up as a cache-read collapse on the next step.
|
||||
|
||||
|
||||
@@ -499,7 +499,19 @@ Factory and backend registration use different reentrancy orderings around the s
|
||||
|
||||
### Durable session ownership carries the scope key
|
||||
|
||||
The [session-immutability RFC](2026-06-11-dev-invariants-over-deep-readonly.md#session-owns-immutable-history) owns header, event, and snapshot semantics. Agent-scope correctness adds one requirement: the store keeps append observers, accepted registry IDs, and captured scope carriers in private owner state rather than caller-writable fields. Outside JavaScript therefore cannot rename a stored session or redirect later `session/event` delivery by mutating visible state.
|
||||
The [session-immutability RFC](2026-06-11-dev-invariants-over-deep-readonly.md#session-owns-immutable-history) owns header, event, and snapshot semantics. Agent-scope correctness adds one requirement: the store keeps append publication, accepted registry IDs, and captured scope carriers in private owner state rather than caller-writable fields. Outside JavaScript therefore cannot rename a stored session or redirect later `session/event` delivery by mutating visible state.
|
||||
|
||||
An entered session treats append as one synchronous acceptance-and-publication boundary:
|
||||
|
||||
1. Capture the current store attachment and its private attachment epoch, keep the attachment live, then materialize and deep-freeze the caller's event data.
|
||||
2. Reject if caller getters changed either value; the epoch catches even a transient attach-then-detach that restores the original hook lookup. The event must not become live without the store hooks that accepted it.
|
||||
3. Resolve the exact scoped `session/event` callback list before commit. Cordis runs `internal/dispatch` during this step, so development invariants can still reject a bad candidate while the log is unchanged. Resolution uses a throwaway mutable argument array; replacing its accepted session or event rejects before commit, and product callbacks later receive a fresh fixed tuple.
|
||||
4. Push the event into the log. This is the commit point.
|
||||
5. Invoke the captured callbacks with per-listener containment and best-effort non-throwing failure reporting, then release the attachment barrier and honor any detach requested during acceptance or publication.
|
||||
|
||||
The boundary rejects a reentrant `append()` until the outer callback list drains. Without that guard, an early observer could append event N+1 before a later persistence observer had received event N, reversing delivery relative to the log. Detach is deferred for the same interval, so no event can commit after `session/disposed` or lose its publication hooks. Once the push occurs, synchronous observer throws and returned-promise rejections are logged and contained rather than escaping as a false append failure or starving later observers.
|
||||
|
||||
`SessionStore.flush()` uses the same pre-dispatch fixed-tuple check but remains an awaited durability barrier rather than an observe-only publication. It starts every captured listener synchronously, converts a synchronous throw into that listener's rejected result so later listeners still start, waits for every result to settle, and only then rejects with the first failed listener in registration order. One broken backend therefore cannot make the caller return while another backend is still flushing.
|
||||
|
||||
Approval requests follow the same async boundary at smaller scale: one capture preserves exact agent/signal identities, copies scalar fields, captures the session once, and drives `approval/asked`, scoped policy, cancellation, and `approval/decided` from that record.
|
||||
|
||||
@@ -915,7 +927,7 @@ The marker is compile-time only; JavaScript, casts, and direct Cordis dispatch c
|
||||
|
||||
### Development invariants inspect actual dispatch
|
||||
|
||||
The invariants plugin observes Cordis's internal dispatch before listener delivery. Every scoped event requires a marked carrier, and events whose arguments expose the subject require the carrier key to be the same object.
|
||||
The invariants plugin uses Cordis's internal dispatch as the pre-delivery enforcement point. Every scoped event requires a marked carrier, and events whose arguments expose the subject require the carrier key to be the same object. For `session/event`, callback resolution also precedes the log push: the plugin validates and stages the exact candidate there, then advances its live trace only when the same committed event reaches its contained post-commit listener. A later internal check can therefore veto without advancing either log or trace. Both halves of this oracle are explicitly global, so mounting the plugin under a scoped context cannot stage a foreign event without also applying its committed transition.
|
||||
|
||||
Session and subagent payloads do not expose their owner key directly, so their service centralizes key selection and the invariant proves carrier presence. Additional invariants reject an assembly whose `agent` and `scope` disagree and a turn opened before `agent/session-start`.
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ The `escalation-rejected` twin ends in `{"outcome": "rejected"}` instead: nothin
|
||||
|
||||
#### The seam: mechanism and policy split
|
||||
|
||||
`ApprovalService.request(req)` always resolves to a closed `ApprovalOutcome` — `allowed-once` / `rejected` / `cancelled` / `unavailable` — and never rejects. The service synchronously snapshots and shallow-freezes the accepted request before its first asynchronous boundary: scalar fields are copied while the agent and `AbortSignal` remain exact identity capabilities, so later caller mutation cannot redirect scope, payload, cancellation, or either audit event. The service dispatches the `approval/request` waterfall, races the captured signal (abort settles `cancelled`; a late answer is discarded, never double-audited), contains a throwing answerer as `unavailable`, normalizes a rogue non-vocabulary return to `unavailable`, and lands the log-only audit pair `approval/asked`/`approval/decided` (paired by the branded `ApprovalRequestId`) on the captured agent's captured session log. A session observer runs after an event enters the append-only log; if one throws, the service recognizes the recorded event, contains the callback failure, and completes the pair. Grants are one-shot by definition: `allowed-once` authorizes the single asked-about action, never a class of future ones, and the service stores nothing between requests. The one precondition: `request()` throws (before appending anything) when the agent's session has no open turn — the audit pair must be turn-enclosed, the turn being the durable log's commit/replay boundary (a bare event between turns is dropped as crash tail on reload); every ask path runs mid-turn already, and idle asks are a deferred design.
|
||||
After request validation and a successful `approval/asked` append, the answerer phase always resolves to a closed `ApprovalOutcome` — `allowed-once` / `rejected` / `cancelled` / `unavailable`. The service synchronously snapshots and shallow-freezes the accepted request before its first asynchronous boundary: scalar fields are copied while the agent and `AbortSignal` remain exact identity capabilities, so later caller mutation cannot redirect scope, payload, cancellation, or either audit event. The service dispatches the `approval/request` waterfall, races the captured signal (abort settles `cancelled`; a late answer is discarded, never double-audited), contains a throwing answerer as `unavailable`, normalizes a rogue non-vocabulary return to `unavailable`, and lands the log-only audit pair `approval/asked`/`approval/decided` (paired by the branded `ApprovalRequestId`) on the captured agent's captured session log. Request acceptance and either pre-commit audit append may still reject; returning a decision that could not be logged would violate the pair. Session owns post-commit observer containment, so a callback failure cannot turn an authoritative audit append into a rejected request or suppress the matching event. Grants are one-shot by definition: `allowed-once` authorizes the single asked-about action, never a class of future ones, and the service stores nothing between requests. `request()` also throws before appending anything when the agent's session has no open turn — the audit pair must be turn-enclosed, the turn being the durable log's commit/replay boundary (a bare event between turns is dropped as crash tail on reload); every ask path runs mid-turn already, and idle asks are a deferred design.
|
||||
|
||||
Answerers are the policy, and they are `approval/request` waterfall listeners. The waterfall buys exactly what the seam needs: with zero listeners the dispatch falls through to the caller-supplied default — `unavailable`, so fail-closed needs no configuration and no code in any deployment; a listener that recognizes the request's agent answers by returning an outcome without calling `next()` (the decision slot is single-occupancy, first answer wins — the same documented semantics as the `fs/write-intent` gate); a listener that does not recognize the agent MUST delegate via `next()` so another answerer or the default gets the question; and listeners dispose with their owning fiber, so an unloaded UI plugin degrades the next ask to `unavailable` instead of leaving a dangling channel. Registration order across sibling plugins is not load-order deterministic (the loader starts siblings concurrently), so a deployment composes ONE terminal answerer and reserves `prepend` listeners for decide-or-delegate gates.
|
||||
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
The loop records the canonical transcript in `SessionEvent` and also emitted a parallel set of live `agent/*` boundary mirror events: `agent/turn-start`, `agent/turn-end`, `agent/step-start`, and `agent/step-end`. The mirrors made consumers choose between two sources of truth for the SAME durable fact. ACP already chose the session log for the editor-facing transcript because a throwing peer listener can prevent later `agent/*` listeners from observing a boundary, while the session event was already appended. The stdio UI was the only production consumer that still rendered turn boundaries from the mirror events; it already rendered tool calls and results from `session/event`.
|
||||
The loop records the canonical transcript in `SessionEvent` and also emitted a parallel set of live `agent/*` boundary mirror events: `agent/turn-start`, `agent/turn-end`, `agent/step-start`, and `agent/step-end`. The mirrors made consumers choose between two sources of truth for the SAME durable fact. ACP already chose the session log for the editor-facing transcript because it is the one durable, replayable record; consuming a live mirror would require reconciling its timing with the boundary already stored in that log. The stdio UI was the only production consumer that still rendered turn boundaries from the mirror events; it already rendered tool calls and results from `session/event`.
|
||||
|
||||
This duplication is not free. Every lifecycle change had to update the session event, the mirror event, docs, invariants, tests, and snapshot expectations. The duplicate boundary events also made failure ordering subtle: a turn can be durably closed before a live `agent/turn-end` listener runs, so a post-boundary listener failure has no valid in-log position left and must be reported out of band.
|
||||
|
||||
|
||||
@@ -275,39 +275,21 @@ export class ReactLoopAgent implements Agent {
|
||||
// No turn open: wrap the injection in a one-shot turn so every event stays
|
||||
// turn-enclosed (the durability/replay boundary is the turn).
|
||||
const turn = lastTurnNumber(this.session) + 1
|
||||
// Once turn/start enters the log, a turn/end is OWED no matter what — even
|
||||
// if a throwing `session/event` listener escapes from the turn/start append
|
||||
// (Session.append pushes the event BEFORE notifying listeners) or the
|
||||
// context/message append throws (non-serializable content, throwing
|
||||
// listener). The finally re-checks the log via isTurnOpen() and closes the
|
||||
// turn if one was actually opened, so the log never carries a permanently
|
||||
// open injection turn that would corrupt later turns/replay. (If the
|
||||
// turn/start append throws BEFORE pushing — non-serializable trigger, which
|
||||
// can't happen for our fixed trigger — no turn was opened and none is owed.)
|
||||
// Once turn/start enters the log, a turn/end is owed even if the message
|
||||
// append fails acceptance or pre-commit validation. The finally re-checks
|
||||
// the log and closes only a turn that actually opened; post-commit observers
|
||||
// are contained by Session and cannot create a false append failure.
|
||||
try {
|
||||
this.session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
|
||||
this.session.append('context/message', { content, source }, { surfaceOp: 'append' })
|
||||
} finally {
|
||||
// Close the turn if turn/start made it into the log. Contain a throwing
|
||||
// turn/end listener: Session.append pushes before notifying, so a throw
|
||||
// here still leaves turn/end in the log (the turn is balanced) — swallow
|
||||
// it so it neither replaces the original exception nor skips the flush
|
||||
// decision below. (It surfaces through the flush path is not needed; the
|
||||
// turn-balance contract is what matters and it holds.)
|
||||
// Close the turn if turn/start made it into the log. A pre-commit veto
|
||||
// must escape rather than being mistaken for a committed turn/end.
|
||||
if (isTurnOpen(this.session)) {
|
||||
try {
|
||||
this.session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
} catch {
|
||||
// turn/end is already in the log (pushed before the listener threw),
|
||||
// so the turn is balanced; the throw is the listener's bug.
|
||||
}
|
||||
this.session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
// Decide the durability checkpoint from the LOG, not a flag: a turn was
|
||||
// recorded iff this turn's turn/start is logged (it may have been closed
|
||||
// by a throwing-listener turn/end above, which still counts). A
|
||||
// `turnRecorded` boolean set after append('turn/end') would be skipped by
|
||||
// a throwing turn/end listener, losing the flush for a balanced in-memory
|
||||
// turn (crash before the next turn/dispose would drop the idle injection).
|
||||
// Decide the durability checkpoint from the log: an accepted one-shot
|
||||
// turn must be flushed even when its message append was the failing step.
|
||||
const turnRecorded = this.session.events.some(e => e.type === 'turn/start' && e.data.turn === turn)
|
||||
// Checkpoint the one-shot turn for durability, exactly as the loop does at
|
||||
// every turn/end. The loop is NOT running (we are idle), so nothing else
|
||||
|
||||
@@ -291,15 +291,14 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
|
||||
// the previous turn/end), where the persistence backend drops it as a
|
||||
// crash tail (the turn-enclosure RFC). Report via agent/error + the logger only; the
|
||||
// driver survives and moves on.
|
||||
/* v8 ignore start -- defensive internal-corruption backstop: public
|
||||
* send/steer input is accepted as lossless JSON before enqueue, and
|
||||
* runTurn contains every failure after turn/start. */
|
||||
// Acceptance and internal dispatch validation can reject before
|
||||
// turn/start commits. Report that supported pre-turn failure without
|
||||
// inventing a turn/end for a turn that never opened.
|
||||
const err = toError(error)
|
||||
ctx.logger.warn(`agent "${agent.id}": turn ${turn} failed before it started: ${err.message}`)
|
||||
try {
|
||||
events.emit('agent/error', turn, 0, err)
|
||||
} catch { /* contained: a throwing agent/error listener must not kill the driver */ }
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
|
||||
// Reset the cancel marker UNCONDITIONALLY here, after the turn returns and
|
||||
@@ -346,34 +345,14 @@ async function runTurn(
|
||||
let errorReported = false
|
||||
let terminalStopped = false
|
||||
|
||||
// Close the open step exactly once (idempotent via stepOpen). Step boundaries
|
||||
// are durable session events only — there is no agent/* step emit to mirror
|
||||
// them (see the agent event-domain rule). A throwing step/end session-event
|
||||
// listener must not abort finalization and strand the turn open (turn/end
|
||||
// balance > notifying one bad listener); it is contained and surfaced as a
|
||||
// turn error below.
|
||||
const closeStep = (): boolean => {
|
||||
if (!stepOpen) return false
|
||||
// Close the open step exactly once (idempotent via stepOpen). Post-commit
|
||||
// session/event observers are contained by Session; a pre-commit validator
|
||||
// failure still escapes so the outer recovery path may retry the boundary or
|
||||
// fail loudly without pretending an uncommitted step/end exists.
|
||||
const closeStep = (): void => {
|
||||
if (!stepOpen) return
|
||||
session.append('step/end', { turn, step })
|
||||
stepOpen = false
|
||||
// Session.append pushes step/end BEFORE notifying session/event listeners,
|
||||
// so a throwing listener leaves step/end in the log (balance holds) but
|
||||
// would otherwise abort finalization. Contain it and surface it as a turn
|
||||
// error below.
|
||||
let failure: unknown
|
||||
try {
|
||||
session.append('step/end', { turn, step })
|
||||
} catch (error: unknown) {
|
||||
failure = error
|
||||
}
|
||||
// A throwing step/end session-event listener surfaces as a turn error via
|
||||
// failTurn (idempotent). This prevents a throwing listener from producing a
|
||||
// silent "completed" turn when the step itself succeeded, AND keeps
|
||||
// finalization going when closeStep runs from the outer catch.
|
||||
if (failure !== undefined) {
|
||||
failTurn(toError(failure))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Record a step/turn failure exactly once: set the error reason (carrying the
|
||||
@@ -385,12 +364,9 @@ async function runTurn(
|
||||
const failTurn = (err: CodedError): void => {
|
||||
if (errorReported) return
|
||||
errorReported = true
|
||||
// The turn is always still open here: the only failure that can reach
|
||||
// failTurn once turn/end is appended would be a throwing turn-boundary
|
||||
// listener, and turn boundaries are durable session events with no agent/*
|
||||
// mirror to throw. A throwing `turn/end` session-event listener is already
|
||||
// contained inside closeTurn (append pushes before notifying, so the
|
||||
// boundary is durable). So set the error reason for closeTurn to append.
|
||||
// The turn is still open here. Post-commit observers cannot escape append,
|
||||
// and a pre-commit turn/end veto leaves no closing boundary to overwrite.
|
||||
// Set the reason that the next successful closeTurn will append.
|
||||
reason = { kind: 'error', step, ...errorData(err) }
|
||||
try {
|
||||
events.emit('agent/error', turn, step, err)
|
||||
@@ -400,30 +376,17 @@ async function runTurn(
|
||||
}
|
||||
}
|
||||
|
||||
// Close the turn. Called exactly once per turn — the normal loop exit and the
|
||||
// outer catch are mutually exclusive paths, and this never throws (the append
|
||||
// is contained below), so there is no re-entry to guard against (unlike
|
||||
// closeStep, which the cancel branches and the outer catch can both reach).
|
||||
// Turn boundaries are durable session events only — there is no agent/* turn
|
||||
// emit to mirror them (see the agent event-domain rule).
|
||||
// Close the turn. Post-commit observer failures are contained by Session;
|
||||
// pre-commit validation failures escape to recovery instead of being mistaken
|
||||
// for a committed boundary. Turn boundaries are durable session events only.
|
||||
const closeTurn = (): void => {
|
||||
// Session.append pushes turn/end BEFORE notifying session/event listeners,
|
||||
// so a throwing listener leaves turn/end in the log (the turn is balanced)
|
||||
// but would otherwise escape — from the outer catch it would propagate to
|
||||
// the runLoop backstop. Contain it: the boundary is durable either way, and
|
||||
// finalization must not abort on a bad listener.
|
||||
try {
|
||||
session.append('turn/end', { turn, reason })
|
||||
} catch (error: unknown) {
|
||||
ctx.logger.warn(`agent "${agent.id}": session/event listener threw on turn/end at turn ${turn}: ${toError(error).message}`)
|
||||
}
|
||||
session.append('turn/end', { turn, reason })
|
||||
}
|
||||
|
||||
try {
|
||||
// --- Turn boundary. Once turn/start is appended, a turn/end is owed no
|
||||
// matter what throws below; the catch + closeTurn guarantee it (the catch
|
||||
// decides "owed" from the log via isTurnOpen, so even a throwing turn/start
|
||||
// listener — append pushes before notifying — still gets its turn/end).
|
||||
// matter what throws below; the catch + closeTurn guarantee it. A pre-commit
|
||||
// veto leaves no turn/start in the log and therefore owes no turn/end.
|
||||
session.append('turn/start', { turn, trigger })
|
||||
// Each drained queued message runs the `agent/prompt-submit` waterfall before
|
||||
// it becomes a `user/message` — a hook can rewrite the prompt or block it.
|
||||
@@ -581,20 +544,19 @@ async function runTurn(
|
||||
// messages are snapshotted HERE, in the same synchronous frame as the
|
||||
// step/start append directly below — so the snapshot is exactly the
|
||||
// derivation over the log prefix strictly before step/start's seq.
|
||||
// Anything appended later — by a step/start session/event listener, an
|
||||
// agent/request-window inject(), any concurrent task — lands after the
|
||||
// boundary and joins the NEXT request. An external reconstructor
|
||||
// Anything appended later by the request-window inject seam or a
|
||||
// concurrent task lands after the boundary and joins the NEXT request.
|
||||
// session/event itself is observe-only: append reentrancy is rejected
|
||||
// until the current callback list drains. An external reconstructor
|
||||
// recovers these exact messages by folding the surface over
|
||||
// events[0..stepStartSeq).
|
||||
const boundaryMessages = session.deriveMessages()
|
||||
|
||||
// Mark the step open BEFORE the append: Session.append pushes the event
|
||||
// to the log before notifying session/event listeners, so a THROWING
|
||||
// step/start listener leaves step/start in the log. Setting stepOpen first
|
||||
// means the outer catch's closeStep() then appends the balancing step/end
|
||||
// (turn stays enclosed) instead of stranding an open step under turn/end.
|
||||
stepOpen = true
|
||||
session.append('step/start', { turn, step })
|
||||
// Only a committed step/start creates a balancing obligation. A
|
||||
// pre-commit veto throws before this assignment; post-commit observers
|
||||
// are contained inside Session.append().
|
||||
stepOpen = true
|
||||
|
||||
// Cancel landing in the step-start window: a synchronous `session/event`
|
||||
// step/start listener can cancel after the step is already open. Check
|
||||
@@ -647,7 +609,7 @@ async function runTurn(
|
||||
// Steering that arrived during streaming/tool execution.
|
||||
const steered = drainSteering(agent, handle.inbox, turn)
|
||||
|
||||
if (closeStep()) break
|
||||
closeStep()
|
||||
|
||||
const defaultDecision: ContinuationDecision = { action: stepOutcome.hadToolCalls || steered ? 'continue' : 'stop' }
|
||||
let decision: ContinuationDecision
|
||||
@@ -721,23 +683,11 @@ async function runTurn(
|
||||
// Normal / inline-error loop exit: close the turn.
|
||||
closeTurn()
|
||||
} catch (error: unknown) {
|
||||
// Decide whether this turn was ever opened from the LOG, not a flag.
|
||||
// Session.append pushes the event BEFORE notifying session/event listeners,
|
||||
// so a throwing listener on the `turn/start` append leaves turn/start in the
|
||||
// log even though execution never reached the lines after that append.
|
||||
// Gating on a "turn started" boolean would skip turn/end and leave a
|
||||
// permanently OPEN turn that poisons the next turn/replay (the turn-enclosure RFC). We
|
||||
// check the log for THIS turn's turn/start: present means a turn/end is owed
|
||||
// and the normal-exit `closeTurn()` did NOT run (we are here because a throw
|
||||
// preceded it — the two `closeTurn()` sites are on mutually exclusive paths),
|
||||
// so this catch appends turn/end with the disposed/error reason chosen below.
|
||||
// `closeStep()` IS idempotent (guarded by `stepOpen`) — it may have run
|
||||
// already in a step branch, so running it again is a safe no-op. Absent
|
||||
// turn/start means the append threw BEFORE its push (a non-serializable
|
||||
// trigger outside the public lossless-JSON boundary); nothing was opened, so rethrow
|
||||
// to the runLoop backstop.
|
||||
// Decide whether this turn opened from the LOG, not a speculative flag. A
|
||||
// pre-commit validator or acceptance failure leaves no turn/start and owes
|
||||
// no turn/end, so it propagates to runLoop's backstop. Once turn/start is
|
||||
// present, this path balances any committed step and records the failure.
|
||||
const turnStartLogged = session.events.some(e => e.type === 'turn/start' && e.data.turn === turn)
|
||||
/* v8 ignore next -- defensive internal-corruption path; public inbox input is lossless JSON */
|
||||
if (!turnStartLogged) throw error
|
||||
closeStep()
|
||||
// Choose the close reason. Disposal wins only if no error was already
|
||||
|
||||
@@ -187,10 +187,8 @@ describe('ReactLoopAgent', () => {
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
let flushes = 0
|
||||
ctx.on('session/flush', () => { flushes += 1 })
|
||||
// A session/event listener that throws on the synthetic turn/end. Append
|
||||
// pushes before notifying, so turn/end is in the log (turn balanced) but the
|
||||
// throw must NOT skip the durability checkpoint — the flush decision is made
|
||||
// from the log, not a flag set after the (throwing) append.
|
||||
// Session contains a throwing post-commit turn/end observer. The accepted
|
||||
// boundary still triggers the idle injection's durability checkpoint.
|
||||
let threw = false
|
||||
ctx.on('session/event', (_s, event) => {
|
||||
if (!threw && event.type === 'turn/end') { threw = true; throw new Error('boom turn/end') }
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { CallId, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
@@ -122,13 +123,15 @@ describe('tool JSON parse', () => {
|
||||
})
|
||||
|
||||
describe('toError normalization', () => {
|
||||
it('normalizes non-Error throws from a turn/start session-event listener via toError', async () => {
|
||||
it('normalizes non-Error throws from pre-commit dispatch validation via the runLoop backstop', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
let threwOnce = false
|
||||
ctx.on('session/event', (_session, event) => {
|
||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
||||
if (name !== 'session/event') return
|
||||
const event = args[1] as SessionEvent
|
||||
if (event.type === 'turn/start' && !threwOnce) {
|
||||
threwOnce = true
|
||||
throw 'naked string error' // non-Error throw, normalized via toError
|
||||
@@ -141,11 +144,9 @@ describe('toError normalization', () => {
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]!.message).toBe('naked string error')
|
||||
// A non-Error throw is wrapped in a HarnessError with code UNKNOWN, so the
|
||||
// turn-end error reason carries a routable code instead of degrading.
|
||||
const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error' && turnEnd.data.reason.code).toBe('UNKNOWN')
|
||||
expect(errors[0]).toMatchObject({ message: 'naked string error', code: 'UNKNOWN' })
|
||||
expect(adapter.requests).toEqual([])
|
||||
expect(agent.session.events.some(event => event.type === 'turn/start' || event.type === 'turn/end')).toBe(false)
|
||||
})
|
||||
|
||||
it('normalizes non-Error throws from agent/request waterfall via inline toError in runStep catch', async () => {
|
||||
|
||||
@@ -810,10 +810,10 @@ describe('agent loop', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('stops the turn when a step/end session-event listener failure has recorded an error', async () => {
|
||||
it('contains a step/end observer failure without changing continuation', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'echo', { text: 'x' }),
|
||||
textResponse('should not run'),
|
||||
textResponse('continued after tool call'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
@@ -826,9 +826,8 @@ describe('agent loop', () => {
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
let threw = false
|
||||
// A throwing step/end session-event listener is the surviving boundary-listener
|
||||
// failure path (step boundaries have no agent/* mirror): closeStep contains it
|
||||
// and surfaces it as a turn error rather than stranding the turn open.
|
||||
// Post-commit session observers cannot control the loop. The tool call still
|
||||
// drives the second model request, and the turn completes normally.
|
||||
ctx.on('session/event', (_session, event) => {
|
||||
if (event.type === 'step/end' && !threw) { threw = true; throw new Error('bad step/end listener') }
|
||||
})
|
||||
@@ -836,9 +835,9 @@ describe('agent loop', () => {
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
const turnEnd = agent.session.events.findLast(e => e.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('error')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('completed')
|
||||
})
|
||||
|
||||
it('chains queued messages into consecutive turns', async () => {
|
||||
|
||||
@@ -10,10 +10,7 @@ import { prepareReactLoopAgent } from '../src/agent.ts'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
|
||||
/**
|
||||
* Regression tests for the findings of the first architecture review
|
||||
* (Codex + sub-agent, post phase-1). Each describe block names the finding.
|
||||
*/
|
||||
/** Regression tests for agent-loop boundary, identity, and lifecycle contracts. */
|
||||
|
||||
async function harness(adapter: MockAdapter) {
|
||||
const ctx = new Context()
|
||||
@@ -682,7 +679,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete
|
||||
})
|
||||
})
|
||||
|
||||
describe('P1-6: a step/start session-event listener sees the event already in the log', () => {
|
||||
describe('step boundary publication order', () => {
|
||||
it('the step/start event is in session.events when its session/event listener fires', async () => {
|
||||
const adapter = new MockAdapter([textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
@@ -713,7 +710,7 @@ describe('P1-6: a step/start session-event listener sees the event already in th
|
||||
})
|
||||
})
|
||||
|
||||
describe('P1-5: a started turn (and any open step) is always closed on a boundary throw', () => {
|
||||
describe('turn and step boundary recovery', () => {
|
||||
// Harness with the invariants plugin loaded as an oracle: it throws on
|
||||
// append if the log goes unbalanced (turn/end while a step is open,
|
||||
// turn/start while a turn is open, etc.), so a regression surfaces as an
|
||||
@@ -744,19 +741,13 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
}
|
||||
}
|
||||
|
||||
it('a throwing step/start session-event listener closes the open step then the turn (step/end before turn/end)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('never reached')])
|
||||
it('a throwing step/start observer cannot change a successful turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('request completed')])
|
||||
const ctx = await balancedHarness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-stepstart'), { model: 'mock' })
|
||||
|
||||
// Step boundaries have no agent/* mirror; a throwing step/start session-event
|
||||
// listener is the surviving step-boundary-listener failure. The loop marks
|
||||
// the step open BEFORE appending step/start (Session.append pushes before
|
||||
// notifying, so a post-push listener throw still leaves stepOpen=true), so
|
||||
// the outer catch's closeStep() appends the balancing step/end — the turn
|
||||
// stays enclosed. The invariants oracle (balancedHarness) rejects any
|
||||
// imbalance, so a green run proves turn/start → step/start → step/end →
|
||||
// turn/end nesting holds.
|
||||
// Session owns post-commit containment. The loop sees a successful append,
|
||||
// runs the request, and balances the ordinary step and turn boundaries.
|
||||
let threw = false
|
||||
ctx.on('session/event', (_s, event) => {
|
||||
if (event.type === 'step/start' && !threw) { threw = true; throw new Error('boom step-start') }
|
||||
@@ -769,8 +760,8 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
|
||||
const e = [...agent.session.events]
|
||||
const c = boundaryCounts(agent)
|
||||
expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 1 })
|
||||
expect(errors.map(x => x.message)).toEqual(['boom step-start'])
|
||||
expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 0 })
|
||||
expect(errors).toEqual([])
|
||||
// step/end precedes turn/end (the invariants oracle would reject
|
||||
// turn/end-while-step-open, but assert the order explicitly too).
|
||||
const stepEndIdx = e.findIndex(x => x.type === 'step/end')
|
||||
@@ -779,6 +770,101 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
expect(stepEndIdx).toBeLessThan(turnEndIdx)
|
||||
})
|
||||
|
||||
it('a pre-commit step/start validation failure does not invent a step boundary', async () => {
|
||||
const adapter = new MockAdapter([textResponse('never reached')])
|
||||
const ctx = await balancedHarness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-stepstart-veto'), { model: 'mock' })
|
||||
let rejected = false
|
||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
||||
if (name !== 'session/event') return
|
||||
const event = args[1] as SessionEvent
|
||||
if (event.type === 'step/start' && !rejected) {
|
||||
rejected = true
|
||||
throw new Error('reject step-start before commit')
|
||||
}
|
||||
})
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => { errors.push(error) })
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests).toEqual([])
|
||||
expect(boundaryCounts(agent)).toMatchObject({
|
||||
turnStart: 1,
|
||||
turnEnd: 1,
|
||||
stepStart: 0,
|
||||
stepEnd: 0,
|
||||
errors: 1,
|
||||
})
|
||||
expect(errors.map(error => error.message)).toEqual(['reject step-start before commit'])
|
||||
})
|
||||
|
||||
it('a one-shot turn/end validation failure preserves the earlier turn error on retry', async () => {
|
||||
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider failed' } }]
|
||||
const adapter = new MockAdapter([errorStream])
|
||||
const ctx = await balancedHarness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-turnend-veto'), { model: 'mock' })
|
||||
let rejected = false
|
||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
||||
if (name !== 'session/event') return
|
||||
const event = args[1] as SessionEvent
|
||||
if (event.type === 'turn/end' && !rejected) {
|
||||
rejected = true
|
||||
throw new Error('reject first turn-end')
|
||||
}
|
||||
})
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => { errors.push(error) })
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(errors.map(error => error.message)).toEqual(['provider failed'])
|
||||
expect(boundaryCounts(agent)).toMatchObject({
|
||||
turnStart: 1,
|
||||
turnEnd: 1,
|
||||
stepStart: 1,
|
||||
stepEnd: 1,
|
||||
errors: 1,
|
||||
})
|
||||
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({
|
||||
kind: 'error',
|
||||
message: 'provider failed',
|
||||
})
|
||||
})
|
||||
|
||||
it('a one-shot step/end validation failure keeps the step open until retry succeeds', async () => {
|
||||
const adapter = new MockAdapter([textResponse('completed before close validation')])
|
||||
const ctx = await balancedHarness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-stepend-veto'), { model: 'mock' })
|
||||
let rejected = false
|
||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
||||
if (name !== 'session/event') return
|
||||
const event = args[1] as SessionEvent
|
||||
if (event.type === 'step/end' && !rejected) {
|
||||
rejected = true
|
||||
throw new Error('reject first step-end')
|
||||
}
|
||||
})
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => { errors.push(error) })
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(errors.map(error => error.message)).toEqual(['reject first step-end'])
|
||||
expect(boundaryCounts(agent)).toMatchObject({
|
||||
turnStart: 1,
|
||||
turnEnd: 1,
|
||||
stepStart: 1,
|
||||
stepEnd: 1,
|
||||
errors: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('a throwing agent/error listener during a step-error path still balances the turn, loop survives', async () => {
|
||||
// First turn: model stream ends with a finish-error → step error path →
|
||||
// failTurn emits agent/error, whose listener throws. The turn must still
|
||||
@@ -841,13 +927,9 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
})
|
||||
|
||||
it('preserves reason disposed when a pre-step listener disposes then throws (outer-catch disposed branch)', async () => {
|
||||
// Reach the OUTER catch while disposed: an `agent/pre-step` listener requests
|
||||
// disposal AND throws. The throw escapes the pre-step `await` (line ~419) to
|
||||
// the loop's outer catch — BEFORE the post-pre-step disposal check at ~422
|
||||
// gets to run — so the catch sees `isDisposed() && !errorReported` and must
|
||||
// PRESERVE reason=disposed rather than overwrite it with the listener's throw
|
||||
// (disposal is not a failure). This is the surviving path to that sub-branch
|
||||
// now that there is no turn-boundary emit to throw from.
|
||||
// A pre-step listener requests disposal and then throws before the ordinary
|
||||
// post-listener disposal check. The outer catch sees disposal already won
|
||||
// and must preserve reason=disposed rather than rewrite it as a plugin error.
|
||||
const adapter = new MockAdapter([textResponse('never reached')])
|
||||
const ctx = await balancedHarness(adapter)
|
||||
let agent!: ReactLoopAgent
|
||||
@@ -883,16 +965,8 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
expect(errorEmits).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('a throwing session/event listener on the turn/start append still balances the turn', async () => {
|
||||
// Session.append pushes the event BEFORE notifying session/event listeners,
|
||||
// so a listener throwing on turn/start leaves turn/start IN THE LOG. The
|
||||
// loop must therefore still owe (and append) a turn/end — deciding "owed"
|
||||
// from the log via isTurnOpen, not a "turn started" flag that the throw
|
||||
// skipped. Otherwise the turn stays permanently open and poisons the next
|
||||
// turn/replay (the turn-enclosure RFC). (Uses the plain harness — NOT the invariants
|
||||
// oracle — because the throwing listener is itself a session/event
|
||||
// subscriber.)
|
||||
const adapter = new MockAdapter([textResponse('turn 2')])
|
||||
it('a throwing turn/start observer cannot starve the loop or later turns', async () => {
|
||||
const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-preturn'), { model: 'mock' })
|
||||
|
||||
@@ -906,12 +980,9 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// The error was surfaced exactly once via agent/error.
|
||||
expect(errors.map(e => e.message)).toEqual(['boom turn/start append'])
|
||||
// The turn is BALANCED: turn/start is in the log (it was pushed before the
|
||||
// listener threw), so a turn/end was owed and appended — no open turn. The
|
||||
// last turn-boundary event being turn/end is exactly the loop's isTurnOpen
|
||||
// check (no open turn remains).
|
||||
expect(errors).toEqual([])
|
||||
// Session contains the observer failure per listener, so the committed turn
|
||||
// remains visible to later observers and executes normally.
|
||||
const types = [...agent.session.events].map(e => e.type)
|
||||
expect(types.filter(t => t === 'turn/start')).toHaveLength(1)
|
||||
expect(types.filter(t => t === 'turn/end')).toHaveLength(1)
|
||||
@@ -922,15 +993,10 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
// loop survives: a second turn runs normally.
|
||||
send(agent, 'second')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('a throwing step/end session-event listener during a successful step ends the turn as error, not completed', async () => {
|
||||
// closeStep() must surface a throwing step/end listener via failTurn so the
|
||||
// turn ends with reason error, not a silent "completed" with the throw
|
||||
// swallowed. Regression test for the closeStep() catch that previously
|
||||
// swallowed the throw in the normal (no-tool, no-steering) path. (Step
|
||||
// boundaries have no agent/* mirror; the session-event listener is the path.)
|
||||
it('a throwing step/end observer cannot rewrite the turn outcome', async () => {
|
||||
const adapter = new MockAdapter([textResponse('all good'), textResponse('turn 2 ok')])
|
||||
const ctx = await balancedHarness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-stepend-throw'), { model: 'mock' })
|
||||
@@ -946,11 +1012,10 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const c = boundaryCounts(agent)
|
||||
// step opened and closed; exactly one error turn-end; turn balanced.
|
||||
expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 1 })
|
||||
expect(errors.map(e => e.message)).toEqual(['boom step-end'])
|
||||
expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 0 })
|
||||
expect(errors).toEqual([])
|
||||
expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason)
|
||||
.toEqual({ kind: 'error', step: 1, message: 'boom step-end' })
|
||||
.toEqual({ kind: 'completed' })
|
||||
|
||||
// step/end precedes turn/end (ordering contract)
|
||||
const e = [...agent.session.events]
|
||||
@@ -968,14 +1033,11 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
expect(c2.stepStart).toBe(c2.stepEnd)
|
||||
})
|
||||
|
||||
it('a throwing session/event listener on step/end during finalization still appends turn/end', async () => {
|
||||
it('a throwing step/end observer cannot interrupt error finalization', async () => {
|
||||
// A finish-error stream opens a step then fails it, driving finalization
|
||||
// through closeStep() with the step open. closeStep appends step/end; a
|
||||
// session/event listener throwing on THAT must not abort the catch before
|
||||
// closeTurn — step/end is already logged (balance holds) and the throw is
|
||||
// contained + surfaced via failTurn, so turn/end is still appended. (The
|
||||
// failed step itself also routes through failTurn; the step/end-listener
|
||||
// throw is the second, contained, failure.)
|
||||
// through closeStep() with the step open. Session contains the observer
|
||||
// failure after committing step/end, so closeTurn still records the model
|
||||
// failure and balances the turn.
|
||||
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }]
|
||||
const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')])
|
||||
const ctx = await harness(adapter)
|
||||
@@ -996,7 +1058,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
expect(e.some(x => x.type === 'step/end')).toBe(true)
|
||||
expect(e.some(x => x.type === 'turn/end')).toBe(true)
|
||||
expect(e.at(-1)?.type).toBe('turn/end')
|
||||
expect(errors.length).toBeGreaterThanOrEqual(1) // surfaced via agent/error
|
||||
expect(errors.map(error => error.message)).toEqual(['provider 500'])
|
||||
|
||||
// loop survives.
|
||||
send(agent, 'again')
|
||||
@@ -1005,12 +1067,8 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
})
|
||||
|
||||
it('a throwing session/event listener on turn/end is contained (turn still balanced, loop survives)', async () => {
|
||||
// closeTurn appends turn/end; Session.append pushes it BEFORE notifying
|
||||
// session/event listeners, so a throwing listener leaves turn/end in the log
|
||||
// (the turn is balanced) but must not escape — from the normal-path closeTurn
|
||||
// it would otherwise propagate; the append is contained so the loop continues.
|
||||
// Turn boundaries are durable session events only (no agent/* mirror), so this
|
||||
// session/event append-notify throw is the sole turn-end-listener failure path.
|
||||
// Session contains the observer failure after committing turn/end, so the
|
||||
// boundary stays authoritative and the loop continues normally.
|
||||
const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-turnendappend'), { model: 'mock' })
|
||||
@@ -1036,7 +1094,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
})
|
||||
})
|
||||
|
||||
describe('P1-7: tool/result is logged under the originating call.id, not result.callId', () => {
|
||||
describe('tool result call identity', () => {
|
||||
it('the loop records tool/result under the model call.id even when a post-execute listener replaces content', async () => {
|
||||
// Model emits a tool-call with id "c1", then a final text turn.
|
||||
const adapter = new MockAdapter([
|
||||
@@ -1116,7 +1174,7 @@ describe('surface: assistant/message omits sourceEventSeqs when no chunks stream
|
||||
|
||||
|
||||
|
||||
describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
|
||||
describe('disposal and cancellation during pre-step assembly', () => {
|
||||
it('disposal during system-prompt assembly drops the about-to-start step as disposed', { timeout: 30000 }, async () => {
|
||||
// Block `system-prompt/assemble` on a promise. Start disposal (which
|
||||
// calls stop() synchronously, setting status=disposed), then release the
|
||||
|
||||
@@ -9,31 +9,31 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
|
||||
### Public API
|
||||
|
||||
- `ctx.sessions.create(id?: SessionId, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number } }): Session` — Create a session. `options.seed` replays/forks an existing event log: the constructor reads each array entry once, then recursively validates and copies every nested value in one pass so validation and storage cannot observe different getter results or erase an exotic prototype before checking it. `options.meta` attaches creation metadata (validated absolute `cwd`, `parentSession` lineage, seed boundary) as the immutable `SessionHeader`: the store rejects an exotic metadata shell, reads every accepted field once, and constructs a detached, deep-frozen header. The store fills `version`/`id` and defaults `createdAt` to now; a caller reconstructing a persisted session passes the original `createdAt` and persisted `seedLength` to preserve them. Disposed with the calling fiber.
|
||||
- `ctx.sessions.flush(session: Session): Promise<void>` Dispatch the awaited `session/flush` durability checkpoint with the carrier captured at enter — THE flush entry point (the loop's turn-end checkpoint and idle injection call it; never dispatch a raw `ctx.parallel`). Rejects a prepared, detached, or stale same-id object instead of inventing a subject-less carrier.
|
||||
- `ctx.sessions.flush(session: Session): Promise<void>` Dispatch the awaited `session/flush` durability checkpoint with the carrier captured at enter — THE flush entry point (the loop's turn-end checkpoint and idle injection call it; never dispatch a raw `ctx.parallel`). Every captured listener starts, the call waits for all of them to settle, and a failure rejects only after the other listeners finish. Rejects a prepared, detached, or stale same-id object instead of inventing a subject-less carrier.
|
||||
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that boundary to be `turn/end`, and create a live child session with lineage metadata.
|
||||
- `ctx.sessions.get(id: SessionId): Session | undefined`
|
||||
- `ctx.sessions.list(): Session[]`
|
||||
|
||||
#### Advanced: ordered-teardown lifecycle primitives
|
||||
|
||||
`create()` covers the common case (the session is owned by the calling fiber). When a session must be torn down **in order with another resource** — so a final flush is captured before the store-owned append observer detaches — `create()`'s self-contained effect is wrong, because a fiber unload disposes sibling effects *concurrently*. For that, split the lifecycle and fold it into the owner's single effect:
|
||||
`create()` covers the common case (the session is owned by the calling fiber). When a session must be torn down **in order with another resource** — so a final flush is captured before the store attachment and publication hooks are removed — `create()`'s self-contained effect is wrong, because a fiber unload disposes sibling effects *concurrently*. For that, split the lifecycle and fold it into the owner's single effect:
|
||||
|
||||
- `ctx.sessions.prepare(id?, options?): Session` — read `options.seed`/`options.meta` once, validate and detach the metadata/header, and construct the `Session` WITHOUT entering it into the store. Same options as `create`.
|
||||
- `ctx.sessions.reserve(id): SessionRegistrationReservation` — hold an unpublished id under the calling fiber and construct its one owned Session through `reservation.prepare(options?)`. `release` is the exact owner effect disposer, so the agent lifecycle can adopt it and keep the ID reserved until scope cleanup quiesces. Until that release, bare `prepare`/`create`/`enter` calls for the id reject; the factory later presents the exact capability to `enter`, making setup-time publication structurally impossible without leaking an abandoned reservation across HMR disposal.
|
||||
- `ctx.sessions.enter(session, reservation?): () => void` — claim the ID across caller-controlled filter/carrier evaluation, then install the module-private `session/event` observer and add the exact session under its accepted key; a reentrant same-ID entry cannot be overwritten. Returns the idempotent, exact-object-guarded DETACH disposer, which clears notification, carrier, and accepted-key state without letting a stale capability delete a replacement. Does NOT emit `session/created` (the caller installs the disposer first, then calls `announce`, so a throwing listener rolls the attach back). It re-checks the id because public `prepare`/`enter` calls may be interleaved. A factory passes the opaque capability from `reserve(id)` so setup cannot enter the reserved session or publish a same-id replacement before the owning transaction.
|
||||
- `ctx.sessions.enter(session, reservation?): () => void` — claim the ID across caller-controlled filter/carrier evaluation, then install the module-private append publication hooks and add the exact session under its accepted key; a reentrant same-ID entry cannot be overwritten. Returns the idempotent, exact-object-guarded DETACH disposer, which clears publication, carrier, and accepted-key state without letting a stale capability delete a replacement. Does NOT emit `session/created` (the caller installs the disposer first, then calls `announce`, so a throwing listener rolls the attach back). It re-checks the id because public `prepare`/`enter` calls may be interleaved. A factory passes the opaque capability from `reserve(id)` so setup cannot enter the reserved session or publish a same-id replacement before the owning transaction.
|
||||
- `ctx.sessions.announce(session): void` — begin the one allowed `session/created` announcement for an entered session; repeat and reentrant calls reject before dispatch. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, so another creation listener cannot observe `session/disposed` before its own `session/created` callback. Detach emits `session/disposed` exactly once, including rollback after a partially delivered creation notification; a never-announced entry emits neither edge.
|
||||
|
||||
`dsh-agent-loop` is the canonical consumer: after unpublished agent setup it enters both session and agent before announcing either, then nests loop stop, agent removal, session detach, and scope unwind in one ordered lifecycle. The final flush therefore settles before this package detaches the session, whether teardown starts from an `AgentHandle` or owner-fiber unload.
|
||||
|
||||
### Live service events
|
||||
|
||||
The store pairs announced creation with disposal, publishes each append, and provides an awaited durability checkpoint. Disposal listener failures, including returned-promise rejections, are contained per observer so teardown cannot be interrupted. Exact `session/*` signatures, modes, and scope-carrier behavior live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md); the append-only payload vocabulary is separately generated into the [persistence catalog](../../../docs/persistence-catalog.md). Persistence consumers write behind from the append notification and drain on the store-owned flush entry point rather than dispatching the event directly.
|
||||
The store pairs announced creation with disposal, publishes each append, and provides an awaited durability checkpoint. Before the log push it resolves the exact scoped `session/event` callback list, including development-time internal dispatch checks; substitution of the accepted session/event tuple rejects while the log is unchanged. The push is then the commit point, and callback throws or returned-promise rejections are logged and contained per observer. A committed append therefore returns normally, later observers still run, and teardown cannot interrupt an in-flight acceptance/publication boundary. Exact `session/*` signatures, modes, and scope-carrier behavior live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md); the append-only payload vocabulary is separately generated into the [persistence catalog](../../../docs/persistence-catalog.md). Persistence consumers write behind from the append notification and drain on the store-owned flush entry point rather than dispatching the event directly.
|
||||
|
||||
### Class: `Session`
|
||||
|
||||
Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
|
||||
|
||||
- `session.append(type, data, opts?): SessionEvent` — synchronous, never blocks on I/O. **Throws** if `data` or surface metadata is not losslessly JSON-serializable (BigInt, function, symbol, undefined, `-0`, non-finite number, circular ref, or an exotic object like Map/Set/Date/class instance). One recursive validate-and-copy pass reads each nested value exactly once and produces the detached value that enters the log, so validation and durability cannot diverge through a stateful getter or a prototype-erasing clone. The accepted event and every nested value are deep-frozen before publication; the returned event and observer notification share that immutable owned record. A third parameter `opts: SurfaceIntent` carries surface metadata: `surfaceOp` and `sourceEventSeqs` are each read once, then the former controls how the event enters the surface linked list and the latter records provenance. Runtime validation accepts only `'append'` or the exact `{ op: 'replace', start, end }` record with non-negative safe-integer bounds, and provenance must be an array of non-negative safe integers; non-surface events reject either field. The marker is **required** for the five `SurfaceEventType` events (every message-producing event must declare how it joins the surface) and rejected by the compiler for non-surface types. The contract is enforced two ways: the typed overload handles a specific event literal, AND runtime checks cover widened unions and raw seed/load logs so invalid metadata can never silently enter or disappear from `deriveMessages()`.
|
||||
- `session.append(type, data, opts?): SessionEvent` — synchronous, never blocks on I/O. **Throws** if `data` or surface metadata is not losslessly JSON-serializable (BigInt, function, symbol, undefined, `-0`, non-finite number, circular ref, or an exotic object like Map/Set/Date/class instance). One recursive validate-and-copy pass reads each nested value exactly once and produces the detached value that enters the log, so validation and durability cannot diverge through a stateful getter or a prototype-erasing clone. The accepted event and every nested value are deep-frozen before publication; the returned event and observer notification share that immutable owned record. An entered session pins its attachment from materialization through observer delivery, rejects if a caller getter changes that attachment, and rejects a reentrant append until the outer callback list drains; these rules prevent an event from bypassing persistence or being delivered out of log order. The log push is the commit point: a synchronous observer throw or returned-promise rejection is logged per observer and cannot turn the committed append into a caller-visible failure or starve later observers. A third parameter `opts: SurfaceIntent` carries surface metadata: `surfaceOp` and `sourceEventSeqs` are each read once, then the former controls how the event enters the surface linked list and the latter records provenance. Runtime validation accepts only `'append'` or the exact `{ op: 'replace', start, end }` record with non-negative safe-integer bounds, and provenance must be an array of non-negative safe integers; non-surface events reject either field. The marker is **required** for the five `SurfaceEventType` events (every message-producing event must declare how it joins the surface) and rejected by the compiler for non-surface types. The contract is enforced two ways: the typed overload handles a specific event literal, AND runtime checks cover widened unions and raw seed/load logs so invalid metadata can never silently enter or disappear from `deriveMessages()`.
|
||||
- `session.deriveMessages(): Message[]` — the LLM message history, CACHED: each surface node is projected exactly once, when first seen (O(new nodes) per call; a surface rewrite rebuilds via `surface.replaceGeneration`). Returns a fresh array snapshot per call over SHARED, deep-frozen `Message` objects — cloned once off the log at projection time, so a consumer can never mutate logged data (mutation throws). The surface is the single source of derived history — there is no raw-log fallback.
|
||||
- `session.deriveEventMessage(event): Message | null` — the per-event projection `deriveMessages()` folds: one event's derived message (an unfrozen clone), or `null` when it produces none (a non-surface event, or an empty-content `assistant/message` hosting only usage). External reconstructors and the dev invariant fold the same function over a log prefix's surface, so no two paths can disagree about what a request's messages were (the reconstructability RFC).
|
||||
- `session.surface: SurfaceManager` — the derived surface, lazily rebuilt from `surfaceOp` markers in the log. Processes only new events (delta) on each access — the log is append-only, so prior events never change. `surface.replaceGeneration` is the rewrite signal: bumped by every folded `replace` and by `invalidate()`, never reset, so an incremental consumer comparing generations cannot be fooled.
|
||||
|
||||
@@ -64,7 +64,12 @@ declare module 'cordis' {
|
||||
'session/disposed'(this: Scoped<Session>, session: Session): void
|
||||
/**
|
||||
* An event was appended to a session log (sync, fire-and-forget). This is
|
||||
* the per-append feed a UI or invariant plugin tails.
|
||||
* the per-append feed a UI or invariant plugin tails. The log push is the
|
||||
* commit point; synchronous throws and returned-promise rejections from
|
||||
* observers are logged and contained per listener, so they cannot make a
|
||||
* committed append appear to fail or starve later listeners. The exact
|
||||
* callback list and Cordis internal-dispatch checks resolve before the push;
|
||||
* callbacks themselves run only after it.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the
|
||||
* session's owner scope, captured when the session was ENTERED (an agent's
|
||||
* session is entered through `agent.ctx`, so its events dispatch in that
|
||||
@@ -279,7 +284,62 @@ function renderThrown(value: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
const appendObservers = new WeakMap<Session, (event: SessionEvent) => void>()
|
||||
/** Best-effort reporting that cannot re-expose an already-contained failure. */
|
||||
function warnContained(ctx: Context, message: string): void {
|
||||
try {
|
||||
ctx.logger.warn(message)
|
||||
} catch {
|
||||
// contained: logger failure must not turn an observe-only callback failure
|
||||
// back into a caller-visible error or an unhandled promise rejection.
|
||||
}
|
||||
}
|
||||
|
||||
type SessionCallback = (...args: unknown[]) => unknown
|
||||
|
||||
/** Resolve one listener snapshot, including Cordis's internal dispatch checks. */
|
||||
function collectSessionCallbacks(ctx: Context, args: unknown[]): SessionCallback[] {
|
||||
return [...ctx.events.dispatch('emit', args)] as SessionCallback[]
|
||||
}
|
||||
|
||||
/** Reject pre-commit dispatch instrumentation that substituted accepted values. */
|
||||
function assertDispatchTuple(name: string, actual: unknown[], expected: unknown[]): void {
|
||||
if (actual.length !== expected.length || actual.some((value, index) => value !== expected[index])) {
|
||||
throw new Error(`${name} internal dispatch replaced the accepted callback tuple`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Invoke one resolved observe-only listener snapshot with per-listener containment. */
|
||||
function invokeContainedSessionObservers(
|
||||
ctx: Context,
|
||||
name: 'session/event' | 'session/disposed',
|
||||
id: SessionId,
|
||||
args: unknown[],
|
||||
callbacks: SessionCallback[],
|
||||
): void {
|
||||
for (const callback of callbacks) {
|
||||
try {
|
||||
const returned: unknown = callback(...args)
|
||||
void Promise.resolve(returned).catch((error: unknown) => {
|
||||
warnContained(ctx, `session "${id}": ${name} listener rejected: ${renderThrown(error)}`)
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
warnContained(ctx, `session "${id}": ${name} listener threw: ${renderThrown(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface SessionAppendHooks {
|
||||
/** Keep the store attachment live through acceptance and publication. */
|
||||
begin(): void
|
||||
/** Resolve the exact observer list before commit; returns its contained publisher. */
|
||||
prepareObservation(event: SessionEvent): () => void
|
||||
/** Release the attachment barrier and honor a deferred detach. */
|
||||
end(): void
|
||||
}
|
||||
|
||||
const appendHooks = new WeakMap<Session, SessionAppendHooks>()
|
||||
/** Identity token replaced on every store attachment or detachment. */
|
||||
const attachmentEpochs = new WeakMap<Session, object>()
|
||||
|
||||
/**
|
||||
* An event-sourced session: an append-only log of {@link SessionEvent}s.
|
||||
@@ -289,6 +349,8 @@ const appendObservers = new WeakMap<Session, (event: SessionEvent) => void>()
|
||||
*/
|
||||
export class Session {
|
||||
private log: SessionEvent[] = []
|
||||
/** True throughout one event's materialization, validation, commit, and publication. */
|
||||
private appendInProgress = false
|
||||
|
||||
/**
|
||||
* Derived surface — a cached linked list of message-producing events.
|
||||
@@ -393,8 +455,11 @@ export class Session {
|
||||
|
||||
/**
|
||||
* Append one typed event to the log and synchronously notify observers via
|
||||
* the store-owned, module-private append observer. The hot path never blocks
|
||||
* on I/O — persistence plugins buffer asynchronously.
|
||||
* the store-owned, module-private publication hooks. The hot path never blocks
|
||||
* on I/O — persistence plugins buffer asynchronously. Once the event enters
|
||||
* the log, the append is committed: observer failures are logged and
|
||||
* contained per listener, so they do not change the return value or prevent
|
||||
* later listeners from observing the same accepted event.
|
||||
*
|
||||
* @param type - The event type (key of {@link SessionEventMap}).
|
||||
* @param data - The event payload; must be JSON-serializable.
|
||||
@@ -416,7 +481,9 @@ export class Session {
|
||||
* copies each nested value once, so a stateful getter cannot supply one value
|
||||
* to validation and another to storage. The event log is the durable source
|
||||
* of truth, so a bad event fails at the append site rather than later during
|
||||
* a backend flush.
|
||||
* a backend flush. A synchronous internal dispatch validation failure or an
|
||||
* append reentered while this acceptance/publication boundary is open also
|
||||
* rejects before the log changes.
|
||||
*/
|
||||
append<T extends SessionEventType>(
|
||||
type: T,
|
||||
@@ -426,60 +493,87 @@ export class Session {
|
||||
if (typeof type !== 'string') {
|
||||
throw new TypeError('session event type must be a string')
|
||||
}
|
||||
const surfaceOpts: SurfaceIntent | undefined = opts[0]
|
||||
const sourceEventSeqs = surfaceOpts?.sourceEventSeqs
|
||||
const surfaceOp = surfaceOpts?.surfaceOp
|
||||
// Surface-eligible events MUST carry a surfaceOp marker — the surface is the
|
||||
// sole source of derived history, so a marker-less message event would be
|
||||
// logged yet vanish from deriveMessages(). The typed `opts` overload makes
|
||||
// the marker mandatory only when `T` is a SPECIFIC SurfaceEventType literal;
|
||||
// when `T` widens to the SessionEventType union (a caller iterating raw
|
||||
// events: `for (const e of log) append(e.type, e.data)`), the conditional
|
||||
// rest collapses to optional and the compiler stops enforcing it. Re-check
|
||||
// at runtime so that loophole can't silently drop history.
|
||||
const surfaceMetadata = {
|
||||
...sourceEventSeqs !== undefined ? { sourceEventSeqs } : {},
|
||||
...surfaceOp !== undefined ? { surfaceOp } : {},
|
||||
if (this.appendInProgress) {
|
||||
throw new Error('session append cannot reenter while another append is being accepted or published')
|
||||
}
|
||||
// The caller still owns the data and metadata objects and could mutate them
|
||||
// after append. Materialize each accepted value exactly once while checking
|
||||
// its JSON vocabulary, so the log cannot drift and a stateful getter cannot
|
||||
// show one value to validation and another to a prototype-erasing clone. The
|
||||
// returned event carries these SAME snapshots.
|
||||
//
|
||||
// Surface metadata accessors are read once into one plain record; the
|
||||
// recursive snapshot then reads each nested value once as it copies it.
|
||||
// Build the event shape with conditional surface fields via spreading.
|
||||
// The result is cast through `unknown` because the conditional spreads
|
||||
// produce an intersection type that the assignability checker can't
|
||||
// narrow to a specific discriminated-union member when T is generic.
|
||||
// This is a safe internal boundary: data and surface metadata are
|
||||
// materialized below before the event enters the log.
|
||||
const dataSnapshot = snapshotJsonValue(data)
|
||||
if (dataSnapshot === undefined) {
|
||||
throw new Error(`session event "${type}" carries non-JSON-serializable data`)
|
||||
const hooks = appendHooks.get(this)
|
||||
const attachmentEpoch = attachmentEpochs.get(this)
|
||||
this.appendInProgress = true
|
||||
try {
|
||||
// Start before reading caller-owned fields: a getter may request detach
|
||||
// or try to append reentrantly. The attachment and sequence boundary stay
|
||||
// stable until this exact acceptance attempt has either failed or reached
|
||||
// every post-commit observer.
|
||||
hooks?.begin()
|
||||
const surfaceOpts: SurfaceIntent | undefined = opts[0]
|
||||
const sourceEventSeqs = surfaceOpts?.sourceEventSeqs
|
||||
const surfaceOp = surfaceOpts?.surfaceOp
|
||||
// Surface-eligible events MUST carry a surfaceOp marker — the surface is the
|
||||
// sole source of derived history, so a marker-less message event would be
|
||||
// logged yet vanish from deriveMessages(). The typed `opts` overload makes
|
||||
// the marker mandatory only when `T` is a SPECIFIC SurfaceEventType literal;
|
||||
// when `T` widens to the SessionEventType union (a caller iterating raw
|
||||
// events: `for (const e of log) append(e.type, e.data)`), the conditional
|
||||
// rest collapses to optional and the compiler stops enforcing it. Re-check
|
||||
// at runtime so that loophole can't silently drop history.
|
||||
const surfaceMetadata = {
|
||||
...sourceEventSeqs !== undefined ? { sourceEventSeqs } : {},
|
||||
...surfaceOp !== undefined ? { surfaceOp } : {},
|
||||
}
|
||||
// The caller still owns the data and metadata objects and could mutate them
|
||||
// after append. Materialize each accepted value exactly once while checking
|
||||
// its JSON vocabulary, so the log cannot drift and a stateful getter cannot
|
||||
// show one value to validation and another to a prototype-erasing clone. The
|
||||
// returned event carries these SAME snapshots.
|
||||
//
|
||||
// Surface metadata accessors are read once into one plain record; the
|
||||
// recursive snapshot then reads each nested value once as it copies it.
|
||||
// Build the event shape with conditional surface fields via spreading.
|
||||
// The result is cast through `unknown` because the conditional spreads
|
||||
// produce an intersection type that the assignability checker can't
|
||||
// narrow to a specific discriminated-union member when T is generic.
|
||||
// This is a safe internal boundary: data and surface metadata are
|
||||
// materialized below before the event enters the log.
|
||||
const dataSnapshot = snapshotJsonValue(data)
|
||||
if (dataSnapshot === undefined) {
|
||||
throw new Error(`session event "${type}" carries non-JSON-serializable data`)
|
||||
}
|
||||
const surfaceMetadataSnapshot = snapshotJsonValue(surfaceMetadata)
|
||||
if (surfaceMetadataSnapshot === undefined) {
|
||||
throw new Error(`session event "${type}" carries non-JSON-serializable surface metadata`)
|
||||
}
|
||||
assertSurfaceMetadataShape(
|
||||
type,
|
||||
(surfaceMetadataSnapshot as { surfaceOp?: unknown }).surfaceOp,
|
||||
(surfaceMetadataSnapshot as { sourceEventSeqs?: unknown }).sourceEventSeqs,
|
||||
)
|
||||
if (appendHooks.get(this) !== hooks || attachmentEpochs.get(this) !== attachmentEpoch) {
|
||||
throw new Error('session attachment changed while append input was being accepted')
|
||||
}
|
||||
const event = {
|
||||
type,
|
||||
seq: this.log.length,
|
||||
time: Date.now(),
|
||||
data: dataSnapshot,
|
||||
...surfaceMetadataSnapshot,
|
||||
} as unknown as SessionEvent<T>
|
||||
const acceptedEvent = deepFreeze(event)
|
||||
// Resolve dispatch before the log push. Cordis runs internal/dispatch
|
||||
// while producing this list; if instrumentation rejects the carrier, the
|
||||
// append still fails before commit. The resolved callbacks themselves are
|
||||
// observe-only and run with per-listener containment after the push.
|
||||
const publish = hooks?.prepareObservation(acceptedEvent as unknown as SessionEvent)
|
||||
this.log.push(acceptedEvent as unknown as SessionEvent)
|
||||
this.eventsSnapshot = undefined
|
||||
publish?.()
|
||||
return acceptedEvent
|
||||
} finally {
|
||||
try {
|
||||
hooks?.end()
|
||||
} finally {
|
||||
this.appendInProgress = false
|
||||
}
|
||||
}
|
||||
const surfaceMetadataSnapshot = snapshotJsonValue(surfaceMetadata)
|
||||
if (surfaceMetadataSnapshot === undefined) {
|
||||
throw new Error(`session event "${type}" carries non-JSON-serializable surface metadata`)
|
||||
}
|
||||
assertSurfaceMetadataShape(
|
||||
type,
|
||||
(surfaceMetadataSnapshot as { surfaceOp?: unknown }).surfaceOp,
|
||||
(surfaceMetadataSnapshot as { sourceEventSeqs?: unknown }).sourceEventSeqs,
|
||||
)
|
||||
const event = {
|
||||
type,
|
||||
seq: this.log.length,
|
||||
time: Date.now(),
|
||||
data: dataSnapshot,
|
||||
...surfaceMetadataSnapshot,
|
||||
} as unknown as SessionEvent<T>
|
||||
const acceptedEvent = deepFreeze(event)
|
||||
this.log.push(acceptedEvent as unknown as SessionEvent)
|
||||
this.eventsSnapshot = undefined
|
||||
appendObservers.get(this)?.(acceptedEvent as unknown as SessionEvent)
|
||||
return acceptedEvent
|
||||
}
|
||||
|
||||
/** Cached fold of the request-header events — see {@link requestHeader}. */
|
||||
@@ -674,7 +768,9 @@ export class SessionStore extends Service {
|
||||
private announced = new WeakSet<Session>()
|
||||
/** Entries currently dispatching `session/created`; detach waits for dispatch to unwind. */
|
||||
private announcing = new WeakSet<Session>()
|
||||
/** A detach requested reentrantly from `session/created`. */
|
||||
/** Entries accepting or publishing an append; detach waits for the boundary to unwind. */
|
||||
private appending = new WeakSet<Session>()
|
||||
/** A detach requested reentrantly from creation or append publication. */
|
||||
private pendingDetach = new WeakSet<Session>()
|
||||
/** Unpublished identities held across factory load/setup transactions. */
|
||||
private reservations = new Map<SessionId, SessionRegistrationReservation>()
|
||||
@@ -746,7 +842,7 @@ export class SessionStore extends Service {
|
||||
* fills `version`/`id`/`createdAt`).
|
||||
*
|
||||
* For an agent whose session must be torn down IN ORDER with its loop (so the
|
||||
* loop's final flush is captured before the store-owned observer detaches), do NOT use this
|
||||
* loop's final flush is captured before the store attachment ends), do NOT use this
|
||||
* — fold the session lifecycle into the agent's own effect via
|
||||
* {@link prepare} + {@link enter} + {@link announce} (see `dsh-agent-loop`'s
|
||||
* `startOwned`).
|
||||
@@ -763,7 +859,7 @@ export class SessionStore extends Service {
|
||||
// Single effect owned by the calling fiber. Yield the detach BEFORE
|
||||
// announcing so a throwing `session/created` listener rolls the attach back
|
||||
// (the generator effect disposes already-yielded disposers on a throw)
|
||||
// instead of leaking the store entry + append observer.
|
||||
// instead of leaking the store entry and its publication hooks.
|
||||
this.ctx.effect(function* (this: SessionStore) {
|
||||
yield this.enter(session)
|
||||
this.announce(session)
|
||||
@@ -777,7 +873,7 @@ export class SessionStore extends Service {
|
||||
* Pairs with {@link enter} + {@link announce}: a caller that owns a composite
|
||||
* `ctx.effect` (the agent factory) folds the session lifecycle into that ONE
|
||||
* effect so a fiber unload tears the session + agent down as a single ORDERED
|
||||
* chain rather than as racing sibling effects — which would detach the append observer
|
||||
* chain rather than as racing sibling effects — which would remove the publication hooks
|
||||
* before the loop's closing `session/flush`, dropping the closing events.
|
||||
*
|
||||
* @param id - the session id; omitted, the store mints `session-<n>`.
|
||||
@@ -827,9 +923,9 @@ export class SessionStore extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter a {@link prepare}d session into the store: wire the module-private
|
||||
* append observer to `session/event` and add it to the store. Returns the
|
||||
* DETACH disposer (observer + store removal). Does NOT emit `session/created` —
|
||||
* Enter a {@link prepare}d session into the store: install the module-private
|
||||
* append publication hooks and add it to the store. Returns the DETACH
|
||||
* disposer (hooks + store removal). Does NOT emit `session/created` —
|
||||
* the caller yields this disposer inside its effect and THEN calls
|
||||
* {@link announce}, so a throwing `session/created` listener rolls the attach
|
||||
* back instead of leaking it.
|
||||
@@ -845,7 +941,7 @@ export class SessionStore extends Service {
|
||||
* @param session - a {@link prepare}d session not yet in the store.
|
||||
* @param reservation - the exact unpublished-id capability when a factory
|
||||
* reserved this session across setup.
|
||||
* @returns the detach disposer (observer + store removal). When called from
|
||||
* @returns the detach disposer (publication hooks + store removal). When called from
|
||||
* a synchronous `session/created` listener, removal and disposal wait until
|
||||
* that creation dispatch unwinds.
|
||||
* @throws if a session with this id is already in the store.
|
||||
@@ -863,7 +959,7 @@ export class SessionStore extends Service {
|
||||
if (this.store.has(id) || this.enteringIds.has(id)) {
|
||||
throw new Error(`session "${id}" already exists`)
|
||||
}
|
||||
if (appendObservers.has(session)) throw new Error(`session "${id}" is already attached to a store`)
|
||||
if (appendHooks.has(session)) throw new Error(`session "${id}" is already attached to a store`)
|
||||
this.enteringIds.add(id)
|
||||
// The carrier is decided HERE, once, from the ENTERING context's scope tag
|
||||
// (`this.ctx` is the caller's context — the tracker mechanism): every
|
||||
@@ -889,20 +985,39 @@ export class SessionStore extends Service {
|
||||
}
|
||||
/* v8 ignore next 1 -- enteringIds prevents a same-store commit during carrier construction */
|
||||
if (this.store.has(id)) throw new Error(`session "${id}" already exists`)
|
||||
if (appendObservers.has(session)) throw new Error(`session "${id}" is already attached to a store`)
|
||||
if (appendHooks.has(session)) throw new Error(`session "${id}" is already attached to a store`)
|
||||
this.carriers.set(session, carrier)
|
||||
const emitCtx = this.ctx
|
||||
appendObservers.set(session, (event) => { emitCtx.emit(carrier, 'session/event', session, event) })
|
||||
appendHooks.set(session, {
|
||||
begin: () => { this.appending.add(session) },
|
||||
prepareObservation(event) {
|
||||
// Cordis removes carrier/name in place and exposes the remaining array
|
||||
// to internal/dispatch. Resolve with a throwaway array so an internal
|
||||
// checker cannot replace the tuple later observers receive.
|
||||
const dispatchArgs: unknown[] = [carrier, 'session/event', session, event]
|
||||
const callbackArgs: unknown[] = [session, event]
|
||||
const callbacks = collectSessionCallbacks(emitCtx, dispatchArgs)
|
||||
assertDispatchTuple('session/event', dispatchArgs, callbackArgs)
|
||||
return () => { invokeContainedSessionObservers(emitCtx, 'session/event', id, callbackArgs, callbacks) }
|
||||
},
|
||||
end: () => {
|
||||
this.appending.delete(session)
|
||||
if (this.pendingDetach.has(session) && !this.announcing.has(session)) {
|
||||
this.detachEntered(session, id, carrier)
|
||||
}
|
||||
},
|
||||
})
|
||||
attachmentEpochs.set(session, {})
|
||||
this.acceptedIds.set(session, id)
|
||||
this.store.set(id, session)
|
||||
let entered = true
|
||||
const detach = (): void => {
|
||||
if (!entered) return
|
||||
entered = false
|
||||
// A creation listener may own the advanced detach capability. Keep the
|
||||
// entry and its event observer live until the synchronous creation
|
||||
// dispatch unwinds, then publish the paired disposal edge.
|
||||
if (this.announcing.has(session)) {
|
||||
// A lifecycle listener may own the advanced detach capability. Keep the
|
||||
// entry and its publication hooks live until synchronous creation or append
|
||||
// publication unwinds, then publish the paired disposal edge.
|
||||
if (this.announcing.has(session) || this.appending.has(session)) {
|
||||
this.pendingDetach.add(session)
|
||||
return
|
||||
}
|
||||
@@ -920,7 +1035,8 @@ export class SessionStore extends Service {
|
||||
* remains the exact-identity backstop against future mutation paths */
|
||||
if (this.store.get(id) !== session || this.acceptedIds.get(session) !== id) return
|
||||
const wasAnnounced = this.announced.delete(session)
|
||||
appendObservers.delete(session)
|
||||
appendHooks.delete(session)
|
||||
attachmentEpochs.set(session, {})
|
||||
this.acceptedIds.delete(session)
|
||||
this.carriers.delete(session)
|
||||
this.store.delete(id)
|
||||
@@ -943,38 +1059,40 @@ export class SessionStore extends Service {
|
||||
// throw. Rollback must still pair that partial creation with disposal, and
|
||||
// a listener cannot recursively create a second lifecycle edge.
|
||||
this.announced.add(session)
|
||||
const args: unknown[] = [carrier, 'session/created', session]
|
||||
const dispatchArgs: unknown[] = [carrier, 'session/created', session]
|
||||
const callbackArgs: unknown[] = [session]
|
||||
this.announcing.add(session)
|
||||
try {
|
||||
for (const callback of this.ctx.events.dispatch('emit', args)) {
|
||||
const callbacks = collectSessionCallbacks(this.ctx, dispatchArgs)
|
||||
assertDispatchTuple('session/created', dispatchArgs, callbackArgs)
|
||||
for (const callback of callbacks) {
|
||||
// Synchronous throws intentionally propagate and veto publication; the
|
||||
// yielded detach then emits the paired disposal edge. An async function
|
||||
// is nevertheless assignable to a void listener, so observe its returned
|
||||
// promise: rejection is too late to roll back and must be logged instead
|
||||
// of becoming unhandled.
|
||||
const returned: unknown = callback(...args)
|
||||
const returned: unknown = callback(...callbackArgs)
|
||||
void Promise.resolve(returned).catch((error: unknown) => {
|
||||
this.ctx.logger.warn(`session "${id}": session/created listener rejected: ${renderThrown(error)}`)
|
||||
warnContained(this.ctx, `session "${id}": session/created listener rejected: ${renderThrown(error)}`)
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
this.announcing.delete(session)
|
||||
if (this.pendingDetach.has(session)) this.detachEntered(session, id, carrier)
|
||||
if (this.pendingDetach.has(session) && !this.appending.has(session)) {
|
||||
this.detachEntered(session, id, carrier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Emit the paired teardown notification with per-listener containment. */
|
||||
private emitDisposed(session: Session, carrier: Scoped<Session>, id: SessionId): void {
|
||||
const args: unknown[] = [carrier, 'session/disposed', session]
|
||||
for (const callback of this.ctx.events.dispatch('emit', args)) {
|
||||
try {
|
||||
const returned: unknown = callback(...args)
|
||||
void Promise.resolve(returned).catch((error: unknown) => {
|
||||
this.ctx.logger.warn(`session "${id}": session/disposed listener rejected: ${renderThrown(error)}`)
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`session "${id}": session/disposed listener threw: ${renderThrown(error)}`)
|
||||
}
|
||||
const dispatchArgs: unknown[] = [carrier, 'session/disposed', session]
|
||||
const callbackArgs: unknown[] = [session]
|
||||
try {
|
||||
const callbacks = collectSessionCallbacks(this.ctx, dispatchArgs)
|
||||
invokeContainedSessionObservers(this.ctx, 'session/disposed', id, callbackArgs, callbacks)
|
||||
} catch (error: unknown) {
|
||||
warnContained(this.ctx, `session "${id}": session/disposed dispatch threw: ${renderThrown(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -986,10 +1104,27 @@ export class SessionStore extends Service {
|
||||
* raw `ctx.parallel('session/flush', …)` — one owner, one spelling, and the
|
||||
* scoped-dispatch invariant can pin it.
|
||||
* @param session - the session whose buffered events must reach durable storage.
|
||||
* @returns resolves when every flush listener has settled; rejects if one rejects.
|
||||
* @returns resolves when every flush listener has settled; after all settle,
|
||||
* rejects with the first registered listener failure if any listener failed.
|
||||
*/
|
||||
async flush(session: Session): Promise<void> {
|
||||
await this.ctx.parallel(this.liveEntryFor(session).carrier, 'session/flush', session)
|
||||
const { carrier } = this.liveEntryFor(session)
|
||||
const dispatchArgs: unknown[] = [carrier, 'session/flush', session]
|
||||
const callbackArgs: unknown[] = [session]
|
||||
const callbacks = collectSessionCallbacks(this.ctx, dispatchArgs)
|
||||
assertDispatchTuple('session/flush', dispatchArgs, callbackArgs)
|
||||
const results = await Promise.allSettled(callbacks.map((callback) => {
|
||||
try {
|
||||
return callback(...callbackArgs)
|
||||
} catch (error: unknown) {
|
||||
// Preserve the listener's exact rejection value; flush is a caller-owned
|
||||
// failure boundary, and Cordis listeners may throw arbitrary values.
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}))
|
||||
const failure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected')
|
||||
if (failure !== undefined) throw failure.reason
|
||||
}
|
||||
|
||||
/** Return the exact live session's accepted id and carrier; detached/prepared objects reject. */
|
||||
|
||||
@@ -108,6 +108,40 @@ describe('sessions.flush()', () => {
|
||||
await expect(ctx.sessions.flush(session)).rejects.toThrow('disk full')
|
||||
})
|
||||
|
||||
it('does not let a synchronous flush failure starve later listeners', async () => {
|
||||
const ctx = await mount()
|
||||
const flushed: Session[] = []
|
||||
ctx.on('session/flush', () => { throw new Error('disk full') })
|
||||
ctx.on('session/flush', (session) => { flushed.push(session) })
|
||||
const session = ctx.sessions.create()
|
||||
|
||||
await expect(ctx.sessions.flush(session)).rejects.toThrow('disk full')
|
||||
expect(flushed).toEqual([session])
|
||||
})
|
||||
|
||||
it('waits for slower flush listeners before reporting another listener failure', async () => {
|
||||
const ctx = await mount()
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
let slowStarted = false
|
||||
let settled = false
|
||||
ctx.on('session/flush', () => Promise.reject(new Error('disk full')))
|
||||
ctx.on('session/flush', () => {
|
||||
slowStarted = true
|
||||
return gate.promise
|
||||
})
|
||||
const session = ctx.sessions.create()
|
||||
|
||||
const flushing = ctx.sessions.flush(session)
|
||||
void flushing.finally(() => { settled = true }).catch(() => undefined)
|
||||
await Promise.resolve()
|
||||
expect(slowStarted).toBe(true)
|
||||
expect(settled).toBe(false)
|
||||
|
||||
gate.resolve(undefined)
|
||||
await expect(flushing).rejects.toThrow('disk full')
|
||||
expect(settled).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a never-entered session instead of inventing a carrier', async () => {
|
||||
const ctx = await mount()
|
||||
const scope = await mintScope(ctx, 'owner')
|
||||
@@ -120,6 +154,21 @@ describe('sessions.flush()', () => {
|
||||
expect(flushed).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects internal dispatch substitution before flush callbacks run', async () => {
|
||||
const ctx = await mount()
|
||||
const session = ctx.sessions.create()
|
||||
const replacement = ctx.sessions.create()
|
||||
const flushed: Session[] = []
|
||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
||||
if (name === 'session/flush') args[0] = replacement
|
||||
})
|
||||
ctx.on('session/flush', (candidate) => { flushed.push(candidate) })
|
||||
|
||||
await expect(ctx.sessions.flush(session))
|
||||
.rejects.toThrow('session/flush internal dispatch replaced the accepted callback tuple')
|
||||
expect(flushed).toEqual([])
|
||||
})
|
||||
|
||||
it('clears a detached carrier and rejects stale flushes', async () => {
|
||||
const ctx = await mount()
|
||||
const scope = await mintScope(ctx, 'owner')
|
||||
|
||||
@@ -702,7 +702,7 @@ describe('SessionStore', () => {
|
||||
const session = ctx.sessions.create()
|
||||
expect(created).toEqual([session])
|
||||
|
||||
// The store-owned append observer is module-private. A JavaScript caller
|
||||
// The store-owned append publication hooks are module-private. A JavaScript caller
|
||||
// may create an unrelated property with the old implementation's name,
|
||||
// but cannot suppress the durable event feed.
|
||||
expect(Reflect.set(session, 'onAppend', undefined)).toBe(true)
|
||||
@@ -1120,7 +1120,7 @@ describe('SessionStore', () => {
|
||||
expect(disposed.map(session => session.id)).toEqual(['fixed'])
|
||||
|
||||
// A subsequent create of the SAME id succeeds (the already-exists check is
|
||||
// not wedged) and its store-owned observer is correctly wired (events observable).
|
||||
// not wedged) and its store-owned publication hooks are correctly wired.
|
||||
const events: SessionEvent[] = []
|
||||
ctx.on('session/event', (_session, event) => void events.push(event))
|
||||
const session = ctx.sessions.create(SessionId('fixed'))
|
||||
@@ -1129,6 +1129,277 @@ describe('SessionStore', () => {
|
||||
expect(events).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('contains session/event observer failures after the append commit point', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const warnings: string[] = []
|
||||
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
|
||||
const session = ctx.sessions.create(SessionId('contained-event'))
|
||||
const heard: SessionEvent[] = []
|
||||
let committedBeforeNotify = false
|
||||
ctx.on('session/event', (observedSession, event) => {
|
||||
committedBeforeNotify = observedSession.events.at(-1) === event
|
||||
throw new Error('sync event observer')
|
||||
})
|
||||
ctx.on('session/event', () => Promise.reject(new Error('async event observer')) as never)
|
||||
ctx.on('session/event', (_observedSession, event) => { heard.push(event) })
|
||||
|
||||
let appended!: SessionEvent
|
||||
expect(() => {
|
||||
appended = session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
}).not.toThrow()
|
||||
expect(committedBeforeNotify).toBe(true)
|
||||
expect(session.events).toEqual([appended])
|
||||
expect(heard).toEqual([appended])
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(warnings).toEqual([
|
||||
'session "contained-event": session/event listener threw: Error: sync event observer',
|
||||
'session "contained-event": session/event listener rejected: Error: async event observer',
|
||||
])
|
||||
})
|
||||
|
||||
it('runs internal dispatch validation on one frozen candidate before commit and resets after a veto', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('dispatch-veto'))
|
||||
const validations: Array<{ event: SessionEvent; logLength: number; frozen: boolean }> = []
|
||||
const observed: SessionEvent[] = []
|
||||
let reject = true
|
||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
||||
if (name !== 'session/event') return
|
||||
const [observedSession, event] = args as [Session, SessionEvent]
|
||||
validations.push({
|
||||
event,
|
||||
logLength: observedSession.events.length,
|
||||
frozen: Object.isFrozen(event) && Object.isFrozen(event.data),
|
||||
})
|
||||
if (reject) {
|
||||
reject = false
|
||||
throw new Error('reject first candidate')
|
||||
}
|
||||
})
|
||||
ctx.on('session/event', (_observedSession, event) => { observed.push(event) })
|
||||
|
||||
expect(() => session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})).toThrow('reject first candidate')
|
||||
expect(session.events).toEqual([])
|
||||
expect(observed).toEqual([])
|
||||
|
||||
const appended = session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
expect(validations.map(({ logLength, frozen }) => ({ logLength, frozen }))).toEqual([
|
||||
{ logLength: 0, frozen: true },
|
||||
{ logLength: 0, frozen: true },
|
||||
])
|
||||
expect(validations.map(({ event }) => event.seq)).toEqual([0, 0])
|
||||
expect(validations[1]!.event).toBe(appended)
|
||||
expect(session.events).toEqual([appended])
|
||||
expect(observed).toEqual([appended])
|
||||
})
|
||||
|
||||
it('resolves session/event dispatch before commit so instrumentation failure cannot hide a logged event', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('dispatch-check'))
|
||||
const observed: SessionEvent[] = []
|
||||
ctx.on('internal/dispatch', (_mode, name) => {
|
||||
if (name === 'session/event') throw new Error('dispatch instrumentation rejected the carrier')
|
||||
})
|
||||
ctx.on('session/event', (_observedSession, event) => { observed.push(event) })
|
||||
|
||||
expect(() => session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})).toThrow('dispatch instrumentation rejected the carrier')
|
||||
expect(session.events).toEqual([])
|
||||
expect(observed).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects prepend or append instrumentation that replaces the accepted observer tuple', async () => {
|
||||
for (const prepend of [true, false]) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId(`dispatch-tuple-${prepend}`))
|
||||
const replacementSession = new Session(SessionId('replacement'))
|
||||
const replacementEvent = {
|
||||
type: 'turn/end',
|
||||
seq: 99,
|
||||
time: 1,
|
||||
data: { turn: 99, reason: { kind: 'completed' } },
|
||||
} as SessionEvent
|
||||
const observed: Array<{ session: Session; event: SessionEvent }> = []
|
||||
let replace = true
|
||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
||||
if (name !== 'session/event' || !replace) return
|
||||
args[0] = replacementSession
|
||||
args[1] = replacementEvent
|
||||
}, { prepend })
|
||||
ctx.on('session/event', (observedSession, event) => {
|
||||
observed.push({ session: observedSession, event })
|
||||
})
|
||||
|
||||
expect(() => session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})).toThrow('session/event internal dispatch replaced the accepted callback tuple')
|
||||
expect(session.events).toEqual([])
|
||||
expect(observed).toEqual([])
|
||||
|
||||
replace = false
|
||||
const appended = session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
expect(session.events).toEqual([appended])
|
||||
expect(observed).toEqual([{ session, event: appended }])
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects if a bare session becomes attached while caller data is materialized', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = new Session(SessionId('attach-during-append'))
|
||||
const observed: SessionEvent[] = []
|
||||
let sessionEventDispatches = 0
|
||||
let detach!: () => void
|
||||
ctx.on('internal/dispatch', (_mode, name) => {
|
||||
if (name === 'session/event') sessionEventDispatches += 1
|
||||
})
|
||||
ctx.on('session/event', (_observedSession, event) => { observed.push(event) })
|
||||
const data = {
|
||||
get todos(): TodoItem[] {
|
||||
detach = ctx.sessions.enter(session)
|
||||
ctx.sessions.announce(session)
|
||||
return []
|
||||
},
|
||||
}
|
||||
|
||||
expect(() => session.append('todo/write', data))
|
||||
.toThrow('session attachment changed while append input was being accepted')
|
||||
expect(ctx.sessions.get(session.id)).toBe(session)
|
||||
expect(session.events).toEqual([])
|
||||
expect(sessionEventDispatches).toBe(0)
|
||||
expect(observed).toEqual([])
|
||||
|
||||
const appended = session.append('todo/write', { todos: [] })
|
||||
expect(session.events).toEqual([appended])
|
||||
expect(sessionEventDispatches).toBe(1)
|
||||
expect(observed).toEqual([appended])
|
||||
detach()
|
||||
})
|
||||
|
||||
it('rejects a transient attach and detach while caller data is materialized', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = new Session(SessionId('attach-detach-during-append'))
|
||||
const lifecycle: string[] = []
|
||||
const observed: SessionEvent[] = []
|
||||
ctx.on('session/created', () => { lifecycle.push('created') })
|
||||
ctx.on('session/disposed', () => { lifecycle.push('disposed') })
|
||||
ctx.on('session/event', (_observedSession, event) => { observed.push(event) })
|
||||
const data = {
|
||||
get todos(): TodoItem[] {
|
||||
const detach = ctx.sessions.enter(session)
|
||||
ctx.sessions.announce(session)
|
||||
detach()
|
||||
return []
|
||||
},
|
||||
}
|
||||
|
||||
expect(() => session.append('todo/write', data))
|
||||
.toThrow('session attachment changed while append input was being accepted')
|
||||
expect(ctx.sessions.get(session.id)).toBeUndefined()
|
||||
expect(session.events).toEqual([])
|
||||
expect(lifecycle).toEqual(['created', 'disposed'])
|
||||
expect(observed).toEqual([])
|
||||
})
|
||||
|
||||
it('contains a reentrant observer append without reordering later observers', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const warnings: string[] = []
|
||||
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
|
||||
const session = ctx.sessions.create(SessionId('reentrant-observer'))
|
||||
const heard: SessionEvent[] = []
|
||||
ctx.on('session/event', (observedSession) => {
|
||||
observedSession.append('todo/write', { todos: [] })
|
||||
})
|
||||
ctx.on('session/event', (_observedSession, event) => { heard.push(event) })
|
||||
|
||||
const appended = session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
expect(session.events).toEqual([appended])
|
||||
expect(heard).toEqual([appended])
|
||||
expect(warnings).toEqual([
|
||||
'session "reentrant-observer": session/event listener threw: Error: session append cannot reenter while another append is being accepted or published',
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps observer failures contained when warning output itself throws', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
ctx.logger.warn = (() => { throw new Error('logger unavailable') }) as typeof ctx.logger.warn
|
||||
const session = ctx.sessions.create(SessionId('throwing-logger'))
|
||||
const heard: SessionEvent[] = []
|
||||
ctx.on('session/event', () => { throw new Error('sync observer') })
|
||||
ctx.on('session/event', () => Promise.reject(new Error('async observer')) as never)
|
||||
ctx.on('session/event', (_observedSession, event) => { heard.push(event) })
|
||||
|
||||
let appended!: SessionEvent
|
||||
expect(() => {
|
||||
appended = session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
}).not.toThrow()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(session.events).toEqual([appended])
|
||||
expect(heard).toEqual([appended])
|
||||
})
|
||||
|
||||
it('defers detach through dispatch resolution, commit, and observer publication', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const order: string[] = []
|
||||
const session = ctx.sessions.prepare(SessionId('detach-during-append'))
|
||||
const detach = ctx.sessions.enter(session)
|
||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
||||
if (name !== 'session/event') return
|
||||
const session = args[0] as Session
|
||||
order.push(`resolve:${ctx.sessions.get(session.id) === session ? 'live' : 'detached'}`)
|
||||
detach()
|
||||
})
|
||||
ctx.on('session/event', (session) => {
|
||||
order.push(`observe:${ctx.sessions.get(session.id) === session ? 'live' : 'detached'}`)
|
||||
})
|
||||
ctx.on('session/disposed', (session) => {
|
||||
order.push(`dispose:${ctx.sessions.get(session.id) === session ? 'live' : 'detached'}`)
|
||||
})
|
||||
ctx.sessions.announce(session)
|
||||
|
||||
const appended = session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
|
||||
expect(session.events).toEqual([appended])
|
||||
expect(order).toEqual(['resolve:live', 'observe:live', 'dispose:detached'])
|
||||
expect(ctx.sessions.get(session.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('observes async session/created rejection without rolling back or starving peers', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -1181,6 +1452,46 @@ describe('SessionStore', () => {
|
||||
'session "contained-disposal": session/disposed listener rejected: Error: async disposed',
|
||||
])
|
||||
})
|
||||
|
||||
it('contains internal dispatch failure after session detachment', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const warnings: string[] = []
|
||||
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
|
||||
const heard: Session[] = []
|
||||
ctx.on('internal/dispatch', (_mode, name) => {
|
||||
if (name === 'session/disposed') throw new Error('disposed dispatch instrumentation')
|
||||
})
|
||||
ctx.on('session/disposed', (session) => { heard.push(session) })
|
||||
const session = ctx.sessions.prepare(SessionId('disposed-dispatch'))
|
||||
const detach = ctx.sessions.enter(session)
|
||||
ctx.sessions.announce(session)
|
||||
|
||||
expect(() => { detach() }).not.toThrow()
|
||||
expect(ctx.sessions.get(session.id)).toBeUndefined()
|
||||
expect(heard).toEqual([])
|
||||
expect(warnings).toEqual([
|
||||
'session "disposed-dispatch": session/disposed dispatch threw: Error: disposed dispatch instrumentation',
|
||||
])
|
||||
})
|
||||
|
||||
it('does not let internal dispatch replace the disposed callback tuple', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const replacement = new Session(SessionId('replacement-disposed'))
|
||||
const heard: Session[] = []
|
||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
||||
if (name === 'session/disposed') args[0] = replacement
|
||||
})
|
||||
ctx.on('session/disposed', (session) => { heard.push(session) })
|
||||
const session = ctx.sessions.prepare(SessionId('fixed-disposed-tuple'))
|
||||
const detach = ctx.sessions.enter(session)
|
||||
ctx.sessions.announce(session)
|
||||
|
||||
detach()
|
||||
|
||||
expect(heard).toEqual([session])
|
||||
})
|
||||
})
|
||||
|
||||
describe('todo/write event', () => {
|
||||
|
||||
@@ -6,6 +6,8 @@ Dev-mode event-contract assertions. This pure-listener plugin checks relationshi
|
||||
|
||||
Session itself owns immutable log storage in every composition: it takes one lossless JSON snapshot of each accepted event, deep-freezes that record, and exposes the log through immutable array snapshots. The invariants plugin checks the cross-record and cross-seam rules that storage immutability cannot express.
|
||||
|
||||
Session-log assertions run during Cordis `internal/dispatch`, while `Session.append()` is resolving the `session/event` callback snapshot but before it pushes the candidate into the log. A valid transition is staged by exact event identity and applied to the live trace only when that same committed event reaches the plugin's contained post-commit listener. A later internal dispatch check can therefore veto without advancing either the log or the invariant trace, while ordinary `session/event` observer failures remain observe-only.
|
||||
|
||||
## Plugin
|
||||
|
||||
A functional plugin — register the module namespace (this is what loading by name in `cordis.yml` does):
|
||||
@@ -19,7 +21,7 @@ declare const ctx: Context
|
||||
await ctx.plugin(Invariants)
|
||||
```
|
||||
|
||||
`inject`: `['sessions']` — it reads `ctx.sessions.list()` at apply time to rebuild trace state for sessions that already exist, so a hot reload mid-turn does not falsely reject the next event. It registers only listeners and has no configuration.
|
||||
`inject`: `['sessions']` — it reads `ctx.sessions.list()` at apply time to rebuild trace state for sessions that already exist, so a hot reload mid-turn does not falsely reject the next event. The oracle listeners are explicitly global so pre-commit staging and post-commit application keep the same audience even if the plugin is mounted under a scoped context; their cleanup still belongs to that mounting fiber. The plugin has no configuration.
|
||||
|
||||
## Invariants asserted
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import type { Context } from 'cordis'
|
||||
import { carrierKeyOf, isScopeCarrier } from '@deepseek-ai/dsh-scope'
|
||||
import type { AssembleContext } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { CallId, GenerateOptions } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
|
||||
@@ -70,6 +70,23 @@ interface SessionTrace {
|
||||
surface: number[]
|
||||
}
|
||||
|
||||
/** One accepted event's deferred mutation of a live session trace. */
|
||||
interface SessionTraceTransition {
|
||||
/** Scalar state after the event commits. */
|
||||
scalars: Pick<SessionTrace, 'lastSeq' | 'openTurn' | 'openStep' | 'nextTurn' | 'nextStep'>
|
||||
/** The event's mutation of the open step's pending call set. */
|
||||
pendingCalls:
|
||||
| { kind: 'none' }
|
||||
| { kind: 'add' | 'delete'; callId: CallId }
|
||||
| { kind: 'clear' }
|
||||
/** The event's mutation of the derived surface order. */
|
||||
surface:
|
||||
| { kind: 'none' | 'append' }
|
||||
| { kind: 'replace'; start: number; count: number }
|
||||
/** The committed event sequence to add to the known-sequence set. */
|
||||
seq: number
|
||||
}
|
||||
|
||||
/** Event payload prefix for scoped seams whose first argument names its agent. */
|
||||
interface AgentSubject {
|
||||
agent: Agent
|
||||
@@ -84,14 +101,19 @@ function requireOpenStep(trace: SessionTrace, kind: string, turn: number, step:
|
||||
}
|
||||
}
|
||||
|
||||
/** Assert one appended event against the per-session invariants. */
|
||||
function checkEvent(trace: SessionTrace, event: SessionEvent): void {
|
||||
/** Validate one candidate event without mutating the committed session trace. */
|
||||
function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTransition {
|
||||
// seq is strictly monotonic — the spine of replay equivalence. lastSeq
|
||||
// starts at -1, so the first event (seq 0) passes.
|
||||
if (event.seq <= trace.lastSeq) {
|
||||
throw new InvariantError(`seq must strictly increase: saw ${event.seq} after ${trace.lastSeq}`)
|
||||
}
|
||||
trace.lastSeq = event.seq
|
||||
let openTurn = trace.openTurn
|
||||
let openStep = trace.openStep
|
||||
let nextTurn = trace.nextTurn
|
||||
let nextStep = trace.nextStep
|
||||
let pendingCalls: SessionTraceTransition['pendingCalls'] = { kind: 'none' }
|
||||
let surface: SessionTraceTransition['surface'] = { kind: 'none' }
|
||||
|
||||
// --- Surface invariants ---
|
||||
// Surface metadata (sourceEventSeqs, surfaceOp) is only valid on
|
||||
@@ -133,7 +155,7 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void {
|
||||
// positional range — every shadowed node must appear in sourceEventSeqs.
|
||||
if (se.surfaceOp !== undefined) {
|
||||
if (se.surfaceOp === 'append') {
|
||||
trace.surface.push(event.seq)
|
||||
surface = { kind: 'append' }
|
||||
} else {
|
||||
const { start, end } = se.surfaceOp
|
||||
const startIdx = trace.surface.indexOf(start)
|
||||
@@ -155,9 +177,7 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void {
|
||||
if (missing.length > 0) {
|
||||
throw new InvariantError(`surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(', ')}`)
|
||||
}
|
||||
// Apply the replace to the tracked surface: the new node takes the
|
||||
// range's position so order stays in sync for later replaces.
|
||||
trace.surface.splice(startIdx, shadowed.length, event.seq)
|
||||
surface = { kind: 'replace', start: startIdx, count: shadowed.length }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,8 +196,8 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void {
|
||||
if (event.data.turn !== trace.nextTurn) {
|
||||
throw new InvariantError(`turn/start expected turn ${trace.nextTurn}, got ${event.data.turn}`)
|
||||
}
|
||||
trace.openTurn = event.data.turn
|
||||
trace.nextStep = 1
|
||||
openTurn = event.data.turn
|
||||
nextStep = 1
|
||||
break
|
||||
}
|
||||
case 'turn/end': {
|
||||
@@ -187,8 +207,8 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void {
|
||||
if (trace.openStep !== null) {
|
||||
throw new InvariantError(`turn/end ${event.data.turn} while step ${trace.openStep} is still open`)
|
||||
}
|
||||
trace.openTurn = null
|
||||
trace.nextTurn += 1
|
||||
openTurn = null
|
||||
nextTurn += 1
|
||||
break
|
||||
}
|
||||
case 'step/start': {
|
||||
@@ -202,16 +222,16 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void {
|
||||
if (event.data.step !== trace.nextStep) {
|
||||
throw new InvariantError(`step/start expected step ${trace.nextStep} in turn ${event.data.turn}, got ${event.data.step}`)
|
||||
}
|
||||
trace.openStep = event.data.step
|
||||
openStep = event.data.step
|
||||
break
|
||||
}
|
||||
case 'step/end': {
|
||||
requireOpenStep(trace, 'step/end', event.data.turn, event.data.step)
|
||||
// A result must arrive in the step that issued the call; orphan calls
|
||||
// (a step that errored before its result) do not carry to the next step.
|
||||
trace.pendingCalls.clear()
|
||||
trace.openStep = null
|
||||
trace.nextStep += 1
|
||||
pendingCalls = { kind: 'clear' }
|
||||
openStep = null
|
||||
nextStep += 1
|
||||
break
|
||||
}
|
||||
case 'assistant/chunk': {
|
||||
@@ -224,7 +244,7 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void {
|
||||
}
|
||||
case 'tool/call': {
|
||||
requireOpenStep(trace, 'tool/call', event.data.turn, event.data.step)
|
||||
trace.pendingCalls.add(event.data.callId)
|
||||
pendingCalls = { kind: 'add', callId: event.data.callId }
|
||||
break
|
||||
}
|
||||
case 'tool/result': {
|
||||
@@ -233,9 +253,10 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void {
|
||||
// does NOT hold: a call may have no result — a throwing tool-execution
|
||||
// pipeline step ends the turn with no tool/result, which is legal.)
|
||||
const syntheticInterrupted = event.data.isError && event.data.error?.code === 'interrupted'
|
||||
if (!trace.pendingCalls.delete(event.data.callId) && !syntheticInterrupted) {
|
||||
if (!trace.pendingCalls.has(event.data.callId) && !syntheticInterrupted) {
|
||||
throw new InvariantError(`tool/result for ${event.data.callId} with no prior tool/call in this step`)
|
||||
}
|
||||
pendingCalls = { kind: 'delete', callId: event.data.callId }
|
||||
break
|
||||
}
|
||||
// Turn-enclosure (the turn-enclosure RFC): EVERY session event not handled by a boundary
|
||||
@@ -255,8 +276,52 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void {
|
||||
break
|
||||
}
|
||||
}
|
||||
// Track every seq seen — used above to validate sourceEventSeqs references.
|
||||
trace.knownSeqs.add(event.seq)
|
||||
return {
|
||||
scalars: { lastSeq: event.seq, openTurn, openStep, nextTurn, nextStep },
|
||||
pendingCalls,
|
||||
surface,
|
||||
seq: event.seq,
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply one already-validated transition after its event commits. */
|
||||
function applyTransition(trace: SessionTrace, transition: SessionTraceTransition): void {
|
||||
Object.assign(trace, transition.scalars)
|
||||
switch (transition.pendingCalls.kind) {
|
||||
case 'none':
|
||||
break
|
||||
case 'add':
|
||||
trace.pendingCalls.add(transition.pendingCalls.callId)
|
||||
break
|
||||
case 'delete':
|
||||
trace.pendingCalls.delete(transition.pendingCalls.callId)
|
||||
break
|
||||
case 'clear':
|
||||
trace.pendingCalls.clear()
|
||||
break
|
||||
/* v8 ignore next -- validateEvent produces this closed transition union */
|
||||
default:
|
||||
assertNever(transition.pendingCalls, 'session trace pending-call transition')
|
||||
}
|
||||
switch (transition.surface.kind) {
|
||||
case 'none':
|
||||
break
|
||||
case 'append':
|
||||
trace.surface.push(transition.seq)
|
||||
break
|
||||
case 'replace':
|
||||
trace.surface.splice(transition.surface.start, transition.surface.count, transition.seq)
|
||||
break
|
||||
/* v8 ignore next -- validateEvent produces this closed transition union */
|
||||
default:
|
||||
assertNever(transition.surface, 'session trace surface transition')
|
||||
}
|
||||
trace.knownSeqs.add(transition.seq)
|
||||
}
|
||||
|
||||
/** Validate and apply one event while rebuilding an already-committed log. */
|
||||
function replayEvent(trace: SessionTrace, event: SessionEvent): void {
|
||||
applyTransition(trace, validateEvent(trace, event))
|
||||
}
|
||||
|
||||
/** Legal agent status transitions (the only state machine the loop guarantees). */
|
||||
@@ -284,6 +349,11 @@ function checkTransition(from: AgentStatus | undefined, to: AgentStatus): void {
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const traces = new WeakMap<Session, SessionTrace>()
|
||||
const stagedTransitions = new WeakMap<SessionEvent, {
|
||||
session: Session
|
||||
trace: SessionTrace
|
||||
transition: SessionTraceTransition
|
||||
}>()
|
||||
// Agent status has no stored history to replay; the first observation after
|
||||
// (re-)apply seeds the baseline, so a reload never produces a false positive.
|
||||
const lastStatus = new WeakMap<Agent, AgentStatus>()
|
||||
@@ -304,14 +374,14 @@ export function apply(ctx: Context): void {
|
||||
const trace = freshTrace()
|
||||
traces.set(session, trace)
|
||||
for (const event of session.events) {
|
||||
checkEvent(trace, event)
|
||||
replayEvent(trace, event)
|
||||
}
|
||||
return trace
|
||||
}
|
||||
|
||||
// Every store-created session (the only kind that emits session/event) is
|
||||
// seeded first — via ctx.sessions.list() at apply or session/created — so
|
||||
// the fallback is a defensive guard, never hit in practice.
|
||||
// seeded first — via ctx.sessions.list() at apply or session/created — so the
|
||||
// fallback is a defensive guard, never hit in practice.
|
||||
/* v8 ignore next -- traceFor's fallback: session/event always follows a seed */
|
||||
const traceFor = (session: Session): SessionTrace => traces.get(session) ?? seedSession(session)
|
||||
|
||||
@@ -322,16 +392,51 @@ export function apply(ctx: Context): void {
|
||||
|
||||
// A newly created session may arrive seeded/forked (the constructor copies
|
||||
// the seed WITHOUT emitting session/event), so replay its log here too.
|
||||
ctx.on('session/created', (session) => { seedSession(session) })
|
||||
ctx.on('session/created', (session) => { seedSession(session) }, { global: true })
|
||||
|
||||
ctx.on('session/event', (session, event) => {
|
||||
checkEvent(traceFor(session), event)
|
||||
})
|
||||
// Session resolves dispatch before committing, so internal/dispatch has
|
||||
// already staged this exact event. A later dispatch veto skips every
|
||||
// session/event callback and therefore leaves the live trace unchanged.
|
||||
const staged = stagedTransitions.get(event)
|
||||
/* v8 ignore next 2 -- internal/dispatch stages the exact callback arguments */
|
||||
if (staged === undefined || staged.session !== session) {
|
||||
throw new InvariantError('session/event reached publication without matching pre-commit validation')
|
||||
}
|
||||
stagedTransitions.delete(event)
|
||||
applyTransition(staged.trace, staged.transition)
|
||||
}, { global: true })
|
||||
|
||||
ctx.on('agent/status', (agent, status) => {
|
||||
checkTransition(lastStatus.get(agent), status)
|
||||
lastStatus.set(agent, status)
|
||||
})
|
||||
}, { global: true })
|
||||
|
||||
// --- Setup-drives invariant ---------------------------------------------
|
||||
//
|
||||
// CreateAgentOptions.setup COMPOSES the agent's scoped world; it must not
|
||||
// DRIVE the agent. ReactLoopAgent rejects every driving verb structurally
|
||||
// until rollback-covered publication reaches the session-start boundary;
|
||||
// this event-level invariant remains the cross-implementation backstop for
|
||||
// alternate Agent implementations and raw session writes. A turn/start
|
||||
// candidate before agent/session-start is rejected by internal/dispatch,
|
||||
// before Session commits it. Sessions of agents that exist BEFORE this
|
||||
// plugin applies are marked started (their ordering is unknowable after the
|
||||
// fact — never a false positive on HMR). `agents` is read via ctx.get (a
|
||||
// strict, optional store lookup) rather than injected: the invariants plugin
|
||||
// must load in harnesses that carry no agent registry at all (bare session
|
||||
// tests), where this check simply never trips.
|
||||
const sessionStarted = new WeakSet<Session>()
|
||||
for (const agent of ctx.get('agents')?.list() ?? []) sessionStarted.add(agent.session)
|
||||
const assertSessionStartedBeforeTurn = (session: Session, event: SessionEvent): void => {
|
||||
if (event.type !== 'turn/start' || sessionStarted.has(session)) return
|
||||
const owner = ctx.get('agents')?.list().find(agent => agent.session === session)
|
||||
if (owner === undefined) return
|
||||
throw new InvariantError(
|
||||
`agent "${owner.id}": a turn opened before agent/session-start fired — `
|
||||
+ 'CreateAgentOptions.setup composes the scoped world, it must not drive the agent '
|
||||
+ '(send/steer/inject belong after creation returns)')
|
||||
}
|
||||
|
||||
// --- Scoped-dispatch invariants (the agent-scoping seam) ---------------
|
||||
//
|
||||
@@ -386,6 +491,22 @@ export function apply(ctx: Context): void {
|
||||
`"${name}" was dispatched with a scope carrier keyed to a DIFFERENT subject than its arguments name — `
|
||||
+ 'the carrier key and the event\'s subject must be the same object (use agentEvents(ctx, agent))')
|
||||
}
|
||||
if (name === 'agent/session-start') {
|
||||
// Mark before product listeners run: a prepended session-start listener is
|
||||
// explicitly allowed to inject the first turn's context synchronously.
|
||||
sessionStarted.add((args[0] as Agent).session)
|
||||
}
|
||||
if (name === 'session/event') {
|
||||
const [session, event] = args as [Session, SessionEvent]
|
||||
const trace = traceFor(session)
|
||||
const transition = validateEvent(trace, event)
|
||||
assertSessionStartedBeforeTurn(session, event)
|
||||
// The exact event identity reaches the contained post-commit listener.
|
||||
// A later internal/dispatch listener may still veto; because validation
|
||||
// is pure, abandoning this weakly keyed transition does not advance the
|
||||
// committed trace or retain the session.
|
||||
stagedTransitions.set(event, { session, trace, transition })
|
||||
}
|
||||
// The assembly context must never carry the agent DX field without the
|
||||
// scope layer selector: the assembly would silently miss the agent's
|
||||
// scoped sections/tools (use assembleContextFor(agent)).
|
||||
@@ -399,33 +520,6 @@ export function apply(ctx: Context): void {
|
||||
}
|
||||
}, { global: true })
|
||||
|
||||
// --- Setup-drives invariant ---------------------------------------------
|
||||
//
|
||||
// CreateAgentOptions.setup COMPOSES the agent's scoped world; it must not
|
||||
// DRIVE the agent. ReactLoopAgent rejects every driving verb structurally
|
||||
// until rollback-covered publication reaches the session-start boundary; this event-level invariant remains the
|
||||
// cross-implementation backstop for alternate Agent implementations and raw
|
||||
// session writes. A turn/start appended before agent/session-start is a
|
||||
// creation-time misuse, reported at the appending call site. Sessions of
|
||||
// agents that exist BEFORE this plugin applies are marked started (their
|
||||
// ordering is unknowable after the fact — never a false positive on HMR).
|
||||
// `agents` is read via ctx.get (a strict, optional store lookup) rather
|
||||
// than injected: the invariants plugin must load in harnesses that carry
|
||||
// no agent registry at all (bare session tests), where this check simply
|
||||
// never trips.
|
||||
const sessionStarted = new WeakSet<Session>()
|
||||
for (const agent of ctx.get('agents')?.list() ?? []) sessionStarted.add(agent.session)
|
||||
ctx.on('agent/session-start', (agent) => { sessionStarted.add(agent.session) })
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (event.type !== 'turn/start' || sessionStarted.has(session)) return
|
||||
const owner = ctx.get('agents')?.list().find(agent => agent.session === session)
|
||||
if (owner === undefined) return
|
||||
throw new InvariantError(
|
||||
`agent "${owner.id}": a turn opened before agent/session-start fired — `
|
||||
+ 'CreateAgentOptions.setup composes the scoped world, it must not drive the agent '
|
||||
+ '(send/steer/inject belong after creation returns)')
|
||||
})
|
||||
|
||||
// Request-reconstruction cross-check (the reconstructability RFC): a
|
||||
// loop-built request — frozen envelope + live sessionId is the marker; a
|
||||
// hand-built one-shot (compaction summarize) is unfrozen and skipped — must
|
||||
@@ -501,5 +595,5 @@ export function apply(ctx: Context): void {
|
||||
throw new InvariantError(`llm request for session "${String(session.id)}" diverges from the folded request header`)
|
||||
}
|
||||
return next()
|
||||
}, { prepend: true })
|
||||
}, { global: true, prepend: true })
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import { createScope, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
@@ -21,6 +21,25 @@ function mockAgent(id: string): Agent {
|
||||
}
|
||||
|
||||
describe('session-log invariants', () => {
|
||||
it('keeps pre-commit staging and post-commit application global when mounted under a scope', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
let scopedCtx!: Context
|
||||
await ctx.plugin(Object.assign((inner: Context) => {
|
||||
scopedCtx = createScope(inner, {}).ctx
|
||||
}, { inject: ['sessions'] }))
|
||||
await scopedCtx.plugin(Invariants)
|
||||
const globalSession = ctx.sessions.create(SessionId('global-under-scoped-invariants'))
|
||||
|
||||
expect(() => {
|
||||
globalSession.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
globalSession.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('accepts a well-formed turn/step/tool sequence', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
@@ -37,6 +56,75 @@ describe('session-log invariants', () => {
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('does not advance the trace when a later internal-dispatch listener vetoes', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create(SessionId('dispatch-veto-rollback'))
|
||||
let veto = true
|
||||
ctx.on('internal/dispatch', (_mode, name) => {
|
||||
if (name !== 'session/event' || !veto) return
|
||||
veto = false
|
||||
throw new Error('later dispatch veto')
|
||||
})
|
||||
|
||||
expect(() => session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})).toThrow('later dispatch veto')
|
||||
expect(session.events).toEqual([])
|
||||
|
||||
expect(() => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
}).not.toThrow()
|
||||
expect(session.events.map(event => event.type)).toEqual(['turn/start', 'turn/end'])
|
||||
})
|
||||
|
||||
it('does not stage a substituted candidate from prepended internal instrumentation', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create(SessionId('dispatch-substitution-rollback'))
|
||||
let substitute = true
|
||||
const replacement = {
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
} as const
|
||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
||||
if (name !== 'session/event' || !substitute) return
|
||||
substitute = false
|
||||
args[1] = replacement
|
||||
}, { prepend: true })
|
||||
|
||||
expect(() => session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})).toThrow('session/event internal dispatch replaced the accepted callback tuple')
|
||||
expect(session.events).toEqual([])
|
||||
|
||||
expect(() => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('applies the committed transition after a prepended observer throws', async () => {
|
||||
const { ctx } = await setup()
|
||||
const warnings: string[] = []
|
||||
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
|
||||
const session = ctx.sessions.create(SessionId('postcommit-peer'))
|
||||
ctx.on('session/event', () => { throw new Error('hostile observer') }, { prepend: true })
|
||||
|
||||
expect(() => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
}).not.toThrow()
|
||||
expect(session.events.map(event => event.type)).toEqual(['turn/start', 'turn/end'])
|
||||
expect(warnings).toEqual([
|
||||
'session "postcommit-peer": session/event listener threw: Error: hostile observer',
|
||||
'session "postcommit-peer": session/event listener threw: Error: hostile observer',
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects a non-monotonic seq (replay spine)', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
@@ -861,10 +949,19 @@ describe('scoped-dispatch invariants', () => {
|
||||
expect(() => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
}).toThrow(/turn opened before agent\/session-start/)
|
||||
// After session-start fires, turns open freely.
|
||||
ctx.emit(scopeTarget(agent, agent), 'agent/session-start', agent, 'startup')
|
||||
expect(() => {
|
||||
expect(session.events).toEqual([])
|
||||
// The internal boundary marks the session before even a prepended product
|
||||
// listener runs, so the supported session-start injection pattern can open
|
||||
// and close its one-shot context turn synchronously.
|
||||
ctx.on('agent/session-start', () => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'test' } } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
}, { prepend: true })
|
||||
expect(() => {
|
||||
ctx.emit(scopeTarget(agent, agent), 'agent/session-start', agent, 'startup')
|
||||
}).not.toThrow()
|
||||
expect(session.events.map(event => event.type)).toEqual(['turn/start', 'turn/end'])
|
||||
expect(() => {
|
||||
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
@@ -71,7 +71,7 @@ When the client does NOT advertise the capability, none of the `_meta`/terminal
|
||||
|
||||
## Settle-exactly-once
|
||||
|
||||
A `session/prompt` resolves (or rejects) exactly once, keyed off the canonical session log (the `session/event` stream). One listener captures the prompt's owning turn from the log's `turn/start` and settles on the matching `turn/end` — the durable boundary event (`closeTurn` appends it unconditionally; there is no `agent/*` turn mirror). A prompt settles only on ITS OWN turn (`inflight.turn === turn/end.turn`), so a stale `turn/end` for a previously-cancelled turn whose end arrives late can never settle the wrong prompt. A turn that ends `error` REJECTS the RPC with an internal error carrying the failure message (ACP has no error stop reason); every other reason resolves via the codec. As a fallback, when the agent settles to `idle`/`disposed` with a prompt still pending — e.g. a peer `session/event` listener registered before the bridge threw and starved the bridge's listener — an `agent/status` handler reconciles the prompt from the log (the owning turn's `turn/end`, or `cancelled` if the turn was torn down without one). An empty/whitespace prompt is rejected up front — it would queue no work, so no turn would start and the RPC would hang.
|
||||
A `session/prompt` resolves (or rejects) exactly once, keyed off the canonical session log (the `session/event` stream). One listener captures the prompt's owning turn from the log's `turn/start` and settles on the matching `turn/end` — the durable boundary event (`closeTurn` appends it unconditionally; there is no `agent/*` turn mirror). A prompt settles only on ITS OWN turn (`inflight.turn === turn/end.turn`), so a stale `turn/end` for a previously-cancelled turn whose end arrives late can never settle the wrong prompt. A turn that ends `error` REJECTS the RPC with an internal error carrying the failure message (ACP has no error stop reason); every other reason resolves via the codec. Session contains post-commit observer failures per listener, so another subscriber cannot starve the bridge. As defensive cross-seam reconciliation, an `agent/status` handler checks the log whenever the agent reaches `idle`/`disposed` with a prompt still pending, settling from the owning turn's `turn/end` or as `cancelled` if teardown left no clean boundary. An empty/whitespace prompt is rejected up front — it would queue no work, so no turn would start and the RPC would hang.
|
||||
|
||||
## Permission prompts
|
||||
|
||||
|
||||
@@ -308,11 +308,10 @@ interface SessionRecord {
|
||||
* so a later stale `turn/end` finds no pending prompt.
|
||||
*
|
||||
* `logWatermark` is the session log length at the moment the prompt was
|
||||
* installed (before `send()`). The settle-from-log fallback uses it to infer
|
||||
* the owning `turn/start` from the canonical log even when the live
|
||||
* `session/event` capture was starved (a peer listener that throws on
|
||||
* `turn/start` — see `settleFromLog`): the prompt owns the FIRST `turn/start`
|
||||
* appended at or after this watermark.
|
||||
* installed (before `send()`). Defensive settle-from-log reconciliation uses
|
||||
* it to infer the owning `turn/start` if status reaches idle/disposed before
|
||||
* live correlation settled the prompt: the prompt owns the FIRST message
|
||||
* `turn/start` appended at or after this watermark.
|
||||
*/
|
||||
inflight: {
|
||||
resolve: (reason: StopReason) => void
|
||||
@@ -338,12 +337,11 @@ interface SessionRecord {
|
||||
/**
|
||||
* Drive the in-flight prompt's settle from the harness event stream. The bridge
|
||||
* settles off the durable log: the `turn/end` session event on the
|
||||
* `session/event` feed for the prompt's own turn, with the agent
|
||||
* erroring/settling to idle as a fallback (docs/defensive-patterns.md "honor
|
||||
* cross-seam contracts on BOTH sides") for the case where a throwing peer `session/event` listener
|
||||
* starved the bridge's listener before it saw the boundary. The first of these
|
||||
* to fire settles the prompt; `settle` is then cleared so the others are no-ops
|
||||
* (settle-exactly-once).
|
||||
* `session/event` feed for the prompt's own turn, with idle/disposed status as
|
||||
* defensive log reconciliation (docs/defensive-patterns.md "honor cross-seam
|
||||
* contracts on BOTH sides"). Session contains post-commit observer failures,
|
||||
* so peers cannot starve this feed. The first settlement path clears the slot,
|
||||
* making every later signal a no-op.
|
||||
*/
|
||||
export function apply(ctx: Context, config: AcpConfig): void {
|
||||
// Capture the injected services NOW, during apply(), while we are inside this
|
||||
@@ -527,23 +525,18 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
settleFromTurnEnd(inflight, event.data.reason)
|
||||
})
|
||||
|
||||
// Settle fallback: a `session/event` listener registered BEFORE ACP that
|
||||
// throws (on `turn/start` OR `turn/end`) would, via cordis `emit`'s
|
||||
// stop-on-throw, starve ACP's listener above — the prompt would hang or, if
|
||||
// only the turn number was missed, settle as the wrong outcome. So when the
|
||||
// agent settles to `idle` (or is disposed), reconcile against the canonical
|
||||
// log: determine the prompt's owning turn (the captured `turn`, or — if the
|
||||
// live capture was starved — the FIRST `turn/start` appended at/after the
|
||||
// install-time `logWatermark`), then settle from that turn's `turn/end`
|
||||
// (reject on error, resolve via codec), or `cancelled` if no owning turn ever
|
||||
// started. Never double-settles — clears `inflight` first.
|
||||
// Defensive settle fallback: when the agent reaches idle/disposed while a
|
||||
// prompt is still pending, reconcile against the canonical log. Determine
|
||||
// the owning turn from live capture or the first message turn after the
|
||||
// install-time watermark, then settle from its turn/end; if no clean owning
|
||||
// turn exists, settle cancelled. The slot is cleared first, so this cannot
|
||||
// double-settle against the live session/event path.
|
||||
const settleFromLog = (rec: SessionRecord): void => {
|
||||
const inflight = rec.inflight
|
||||
if (inflight === undefined) return
|
||||
const events = rec.agent.session.events
|
||||
// The owning turn number: the captured one, or — if the live capture was
|
||||
// starved — inferred from the log as the first MESSAGE-triggered turn opened
|
||||
// at/after the watermark. The message-trigger filter matches the live
|
||||
// The owning turn number: the captured one, or inferred from the log as the
|
||||
// first MESSAGE-triggered turn opened at/after the watermark. The filter matches the live
|
||||
// capture: a one-shot `injection` turn a plugin may open between
|
||||
// prompt-install and the prompt's turn is NOT the prompt's turn. Undefined
|
||||
// only if no message turn ever started for this prompt.
|
||||
@@ -568,9 +561,8 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
settleFromTurnEnd(inflight, end.data.reason)
|
||||
}
|
||||
|
||||
// On a settle to idle/disposed, reconcile any still-pending prompt from the
|
||||
// log (covers a starved `session/event` listener — see settleFromLog). A mid-
|
||||
// step disposal that never appended a clean turn/end resolves `cancelled`.
|
||||
// On idle/disposed, reconcile any still-pending prompt from the log. A
|
||||
// mid-step disposal that never appended a clean turn/end resolves `cancelled`.
|
||||
// Demux via the agent→sessionId reverse map.
|
||||
ctx.on('agent/status', (agent, status: AgentStatus) => {
|
||||
const sessionId = bySession.get(agent)
|
||||
@@ -902,9 +894,9 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
// Install the in-flight slot BEFORE send() (send does not synchronously
|
||||
// flip status to running; the session/event listener records the turn
|
||||
// number and settle/rejects it). Capture the log length now as the
|
||||
// watermark: the settle-from-log fallback infers the owning turn/start
|
||||
// as the first one appended at/after it, surviving a starved live
|
||||
// capture. A turn that ends in error rejects this promise (the codec
|
||||
// watermark: defensive status reconciliation can infer the owning
|
||||
// turn/start if status arrives reentrantly after commit but before this
|
||||
// bridge's live callback. A turn that ends in error rejects this promise (the codec
|
||||
// never produces an error stop reason).
|
||||
const stopReason = await new Promise<StopReason>((resolve, reject) => {
|
||||
rec.inflight = { resolve, reject, turn: undefined, logWatermark: rec.agent.session.events.length }
|
||||
@@ -1010,15 +1002,15 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
* quiescence"): for each session settle any pending prompt `cancelled`, then
|
||||
* run that session's {@link AgentHandle} `dispose()` — which stops the loop
|
||||
* (sets `disposed`, aborts the in-flight step), AWAITS the loop's exit (the
|
||||
* final `turn/end` + `session/flush` are captured while the store-owned append observer is still
|
||||
* final `turn/end` + `session/flush` are captured while the store-owned publication hooks are still
|
||||
* attached), unregisters the agent, and removes its session from the store.
|
||||
* The per-session disposes run in parallel. Idempotent — clears the `sessions`
|
||||
* map first and memoizes, so a second call (close racing dispose) is a no-op.
|
||||
* Shared by Cordis disposal AND client disconnect (`conn.closed`).
|
||||
*
|
||||
* Per-agent disposal closes the former pre-step best-effort window — but via
|
||||
* the DISPOSED path, not `cancel()`: the start-disposer resolves `handle.disposed`,
|
||||
* which wakes the parked loop, and `isDisposed()` breaks the loop before a
|
||||
* Per-agent disposal closes the queued-before-run window through the DISPOSED
|
||||
* path, not `cancel()`: the start-disposer resolves `handle.disposed`, which
|
||||
* wakes the parked loop, and `isDisposed()` breaks the loop before a
|
||||
* queued-but-not-yet-running turn can start (a turn cut off mid-flight ends
|
||||
* with reason `disposed`, not `aborted`). A bare client disconnect (resolves
|
||||
* `conn.closed` WITHOUT disposing the fiber) thus leaves NO registered agent
|
||||
|
||||
@@ -160,7 +160,7 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
// The teardown-ORDER guarantee: a per-agent dispose must stop the loop,
|
||||
// AWAIT its exit (so the loop's final `turn/end` + `session/flush` fire
|
||||
// through the still-attached store observer → `session/event`), and only
|
||||
// THEN detach that observer + remove the session. If the order were inverted
|
||||
// THEN remove its publication hooks and session entry. If the order were inverted
|
||||
// (detach first), the closing events would never reach persistence. Drive a
|
||||
// CLEAN turn to completion, dispose JUST the bridge, then re-load the
|
||||
// persisted log from disk and assert the closing turn/end is on disk — the
|
||||
@@ -190,7 +190,7 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
// produced BY the dispose itself. Here the model stream HANGS, so the turn is
|
||||
// still open when teardown runs: the composite agent effect stops the loop,
|
||||
// the loop unwinds and appends `turn/end {disposed}` + runs its final
|
||||
// `session/flush` — all while the store-owned append observer is still attached (the session
|
||||
// `session/flush` — all while the store-owned publication hooks are still attached (the session
|
||||
// detach is the LAST disposer in the same effect's LIFO chain) — and only
|
||||
// THEN is the session detached. If the order were inverted (or the session
|
||||
// were a racing SIBLING effect), the abort-produced `turn/end` would never
|
||||
@@ -255,7 +255,7 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
// into ONE composite effect whose disposers run as a `.then()` chain. The
|
||||
// register disposer emits `agent/disposed`; if a listener throws and the
|
||||
// emit is UNCONTAINED, the rejected chain skips the LATER session-detach
|
||||
// disposer — stranding the session in the store with its append observer attached (a
|
||||
// disposer — stranding the session in the store with its publication hooks attached (a
|
||||
// leak AND a durability hole, since the new design relies on detach
|
||||
// running). The emit must be contained. Register a throwing listener, drive
|
||||
// a clean turn, dispose, and assert the session was STILL removed.
|
||||
|
||||
@@ -3,7 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import { AgentId, agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import {
|
||||
errorResponse,
|
||||
@@ -235,12 +235,9 @@ describe('acp bridge — turn outcomes', () => {
|
||||
expect(failed).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('settles via the log fallback when a prior session/event listener throws (starvation)', async () => {
|
||||
// A peer session/event listener that runs BEFORE the bridge's listener
|
||||
// throws on turn/end (prepend: true puts it first). cordis emit stops at the
|
||||
// throw, so the bridge's session/event listener never sees turn/end and
|
||||
// cannot settle there. The agent/status idle-fallback must reconcile the
|
||||
// prompt from the log so the RPC settles instead of hanging.
|
||||
it('settles successfully when an earlier turn/end observer throws', async () => {
|
||||
// Session contains each post-commit observer failure, so a prepended peer
|
||||
// cannot starve the bridge's live turn/end delivery.
|
||||
harness = await makeBridgeHarness({ storageDir, script: [textResponse('answer')] })
|
||||
harness.ctx.on('session/event', (_s, event) => {
|
||||
if (event.type === 'turn/end') throw new Error('peer listener boom')
|
||||
@@ -250,9 +247,7 @@ describe('acp bridge — turn outcomes', () => {
|
||||
expect(res.stopReason).toBe('end_turn')
|
||||
})
|
||||
|
||||
it('log fallback REJECTS when the starved turn ended in error', async () => {
|
||||
// Same starvation as above, but the turn fails: the idle-fallback must
|
||||
// reject the RPC from the logged turn/end{error}, not resolve.
|
||||
it('still rejects a failed turn when an earlier turn/end observer throws', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, script: [errorResponse('starved boom')] })
|
||||
harness.ctx.on('session/event', (_s, event) => {
|
||||
if (event.type === 'turn/end') throw new Error('peer listener boom')
|
||||
@@ -262,21 +257,49 @@ describe('acp bridge — turn outcomes', () => {
|
||||
.rejects.toThrow(/turn failed: starved boom/)
|
||||
})
|
||||
|
||||
it('log fallback infers the owning turn when turn/START capture is starved', async () => {
|
||||
// A peer listener throws on turn/START (not turn/end): the bridge never
|
||||
it('captures and settles the owning turn when an earlier turn-start observer throws', async () => {
|
||||
// Turn correlation still reaches the bridge after the throwing peer and
|
||||
// captures inflight.turn via the live stream. A throwing turn/start listener
|
||||
// also FAILS the turn (the throw is recorded as the turn's error). Without
|
||||
// the watermark inference the fallback would resolve `cancelled` (the bug);
|
||||
// with it, it infers the owning turn from the log and REJECTS from that
|
||||
// turn's error turn/end. (The model's own error is never reached — the turn
|
||||
// failed at start — so the rejection carries the listener's failure.)
|
||||
harness = await makeBridgeHarness({ storageDir, script: [textResponse('never runs')] })
|
||||
// Session contains post-commit callbacks independently.
|
||||
// The model request and normal turn outcome therefore still occur.
|
||||
harness = await makeBridgeHarness({ storageDir, script: [textResponse('answer')] })
|
||||
harness.ctx.on('session/event', (_s, event) => {
|
||||
if (event.type === 'turn/start') throw new Error('peer listener boom on start')
|
||||
}, { prepend: true })
|
||||
const sessionId = await newSession(harness)
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
|
||||
.rejects.toThrow(/turn failed:/)
|
||||
const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
expect(result.stopReason).toBe('end_turn')
|
||||
})
|
||||
|
||||
it('status reconciliation infers the owning message turn when teardown wins after turn/start', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, script: [textResponse('background completion')] })
|
||||
const sessionId = await newSession(harness)
|
||||
const agent = harness.ctx.agents.get(AgentId(sessionId))!
|
||||
harness.ctx.on('session/event', (session, event) => {
|
||||
if (session !== agent.session || event.type !== 'turn/start') return
|
||||
// Inject the signal ordering the defensive fallback handles: disposal
|
||||
// status after turn/start commits but before ACP's later live observer.
|
||||
// This is event-level simulation; it does not mutate the test agent.
|
||||
agentEvents(harness!.ctx, agent).emit('agent/status', 'disposed')
|
||||
}, { prepend: true })
|
||||
|
||||
const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
expect(result.stopReason).toBe('cancelled')
|
||||
})
|
||||
|
||||
it('status reconciliation can settle from a committed turn/end before live delivery', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, script: [textResponse('answer')] })
|
||||
const sessionId = await newSession(harness)
|
||||
const agent = harness.ctx.agents.get(AgentId(sessionId))!
|
||||
harness.ctx.on('session/event', (session, event) => {
|
||||
if (session !== agent.session || event.type !== 'turn/end') return
|
||||
// Inject a reentrant status signal after the boundary commits to exercise
|
||||
// the defensive log path before ACP's captured callback runs.
|
||||
agentEvents(harness!.ctx, agent).emit('agent/status', 'idle')
|
||||
}, { prepend: true })
|
||||
|
||||
const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
expect(result.stopReason).toBe('end_turn')
|
||||
})
|
||||
|
||||
it('a between-turn injection does not settle the prompt early (message-trigger correlation)', async () => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
User-approval seam. Owns the `ctx.approval` service ([`ApprovalService`](src/index.ts)) and the one-shot permission vocabulary the harness shares: `ApprovalRequest` (agent + tool identity + reason + abort signal), the closed `ApprovalOutcome` union (`allowed-once` / `rejected` / `cancelled` / `unavailable`), the `ApprovalRequestId` brand pairing the two log-only audit events (`approval/asked` / `approval/decided`), and the `approval/request` waterfall the answerers listen on. It lives in the UI group because its purpose is human permission, while remaining channel-neutral: it depends only on Cordis and core vocabulary packages, never on a concrete UI.
|
||||
|
||||
The contract in one line: `ctx.approval.request(req)` puts exactly one question — "may this specific action proceed?" — to whatever answerers the deployment composed, and its decision phase always resolves to an outcome: an aborted signal yields `cancelled`, a throwing or missing answerer yields `unavailable`, and `allowed-once` is a grant for the single asked-about action, never a class of future ones. Acceptance is synchronous: the service reads the request fields and `agent.session` binding once, requires object agent/session identities, a string `toolName`, optional string `callId`/`reason`, and an AbortSignal-shaped live capability, then shallow-freezes a detached request record while preserving the exact `agent` and signal identities. A malformed request rejects before any audit append; later caller mutation cannot redirect scope, payload, cancellation, policy lookup, or either audit event. The other precondition is an open turn on the captured session — the audit pair is turn-enclosed by contract (the turn is the durable log's commit/replay boundary; a bare event between turns is crash-tail garbage on reload), so an idle ask also rejects before appending. Session observers run after an event enters the append-only log; if one throws, the service recognizes that the audit is already authoritative, contains the observer failure, and completes the pair.
|
||||
The contract in one line: `ctx.approval.request(req)` puts exactly one question — "may this specific action proceed?" — to whatever answerers the deployment composed, and its answerer phase always produces an outcome: an aborted signal yields `cancelled`, a throwing or missing answerer yields `unavailable`, and `allowed-once` is a grant for the single asked-about action, never a class of future ones. Acceptance is synchronous: the service reads the request fields and `agent.session` binding once, requires object agent/session identities, a string `toolName`, optional string `callId`/`reason`, and an AbortSignal-shaped live capability, then shallow-freezes a detached request record while preserving the exact `agent` and signal identities. A malformed request rejects before any audit append; later caller mutation cannot redirect scope, payload, cancellation, policy lookup, or either audit event. The other precondition is an open turn on the captured session — the audit pair is turn-enclosed by contract (the turn is the durable log's commit/replay boundary; a bare event between turns is crash-tail garbage on reload), so an idle ask also rejects before appending. Either audit append may reject before commit because returning an unlogged decision would violate the pair. Session contains post-commit observer failures, so an authoritative audit append cannot reject the request or suppress its matching event.
|
||||
|
||||
The service is the mechanism, answerers are the policy. Answerers are `approval/request` waterfall listeners occupying a single decision slot: answer for an agent you own by returning an outcome without calling `next()`, or delegate an agent you don't recognize by calling `next()` — the chain's built-in default is `unavailable`, so a deployment with no answerer (headless, CI) fails closed with zero configuration. Dispatch is keyed by `req.agent`: a listener registered through `agent.ctx` receives only that agent's questions, while a plain-context listener receives every agent's. Registration order across sibling plugins is not load-order deterministic; compose one terminal answerer per deployment and use `prepend` listeners only for decide-or-delegate gates.
|
||||
|
||||
|
||||
@@ -383,20 +383,23 @@ export class ApprovalService extends Service {
|
||||
* contract (the turn is the log's commit/replay boundary; an idle append
|
||||
* would be dropped as crash tail on reload) — and likewise throws before
|
||||
* appending anything when called idle; asking outside a turn is a deferred
|
||||
* design. Once accepted it always resolves to an outcome, never rejects: an
|
||||
* aborted signal yields `'cancelled'`, a missing or throwing answerer yields
|
||||
* `'unavailable'` (fail closed), and a rogue non-vocabulary return value is
|
||||
* normalized to `'unavailable'`. The caller-owned request is synchronously
|
||||
* design. The answerer phase always produces an outcome: an aborted signal
|
||||
* yields `'cancelled'`, a missing or throwing answerer yields `'unavailable'`
|
||||
* (fail closed), and a rogue non-vocabulary return value is normalized to
|
||||
* `'unavailable'`. A failure that prevents either audit append from committing
|
||||
* still rejects; returning an unlogged decision would violate the audit pair.
|
||||
* The caller-owned request is synchronously
|
||||
* snapshotted, so later mutation cannot split routing, dispatch payload,
|
||||
* cancellation, policy lookup, or the audit pair across agents/sessions.
|
||||
* Appends the
|
||||
* `approval/asked`/`approval/decided` audit pair (log-only) around the
|
||||
* decision regardless of outcome. A synchronous session observer failure
|
||||
* after an audit event entered the append-only log is contained; the event
|
||||
* is already authoritative, so the pair still completes and the request
|
||||
* still resolves.
|
||||
* decision regardless of outcome. Session contains each post-commit observer
|
||||
* failure, so an already authoritative audit event cannot make this request
|
||||
* reject or suppress its matching event.
|
||||
* @param req - the pending decision (agent, tool identity, reason, signal).
|
||||
* @returns the closed outcome; `'allowed-once'` is the only grant.
|
||||
* @throws when request acceptance fails, no turn is open, or either audit
|
||||
* event fails before the session append commit point.
|
||||
*/
|
||||
async request(req: ApprovalRequest): Promise<ApprovalOutcome> {
|
||||
// Accept one immutable request shape before the first async boundary. The
|
||||
@@ -476,47 +479,17 @@ export class ApprovalService extends Service {
|
||||
)
|
||||
}
|
||||
const id = ApprovalRequestId(randomUUID())
|
||||
this.appendAudit(session, 'approval/asked', id, () => {
|
||||
Reflect.apply(append, session, ['approval/asked', {
|
||||
id,
|
||||
toolName: accepted.toolName,
|
||||
...accepted.callId !== undefined ? { callId: accepted.callId } : {},
|
||||
...accepted.reason !== undefined ? { reason: accepted.reason } : {},
|
||||
}])
|
||||
})
|
||||
Reflect.apply(append, session, ['approval/asked', {
|
||||
id,
|
||||
toolName: accepted.toolName,
|
||||
...accepted.callId !== undefined ? { callId: accepted.callId } : {},
|
||||
...accepted.reason !== undefined ? { reason: accepted.reason } : {},
|
||||
}])
|
||||
const outcome = await this.decide(accepted, session, acceptedSignal)
|
||||
this.appendAudit(session, 'approval/decided', id, () => {
|
||||
Reflect.apply(append, session, ['approval/decided', { id, outcome }])
|
||||
})
|
||||
Reflect.apply(append, session, ['approval/decided', { id, outcome }])
|
||||
return outcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one audit event while distinguishing a post-append observer throw
|
||||
* from a failure that prevented the event entering the log. `Session.append`
|
||||
* pushes first and then notifies synchronously, so log growth proves the
|
||||
* event is already authoritative; that observer failure is reported and
|
||||
* contained so it cannot reject the approval or suppress its matching event.
|
||||
* @param session - the captured session receiving both audit events.
|
||||
* @param type - the audit event currently being appended.
|
||||
* @param id - the request id, used to identify the contained failure.
|
||||
* @param append - the single concrete `Session.append` call.
|
||||
*/
|
||||
private appendAudit(
|
||||
session: Session,
|
||||
type: 'approval/asked' | 'approval/decided',
|
||||
id: ApprovalRequestId,
|
||||
append: () => void,
|
||||
): void {
|
||||
const length = session.events.length
|
||||
try {
|
||||
append()
|
||||
} catch (error) {
|
||||
if (session.events.length === length) throw error
|
||||
this.ctx.logger.warn(`approval request "${id}": ${type} observer threw after the event was appended`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's effective policy: its own `approval/policy` fold, else the
|
||||
* configured default (the schema already defaulted an omitted policy to
|
||||
|
||||
@@ -323,7 +323,7 @@ describe('ApprovalService.request', () => {
|
||||
const decided = session.events.find((event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided')
|
||||
expect(audit.map(event => event.type)).toEqual(['approval/asked', 'approval/decided'])
|
||||
expect(decided?.data.id).toBe(asked?.data.id)
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('approval/asked observer threw'))
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('session/event listener threw: Error: observer failed after asked append'))
|
||||
})
|
||||
|
||||
it('contains an approval/decided observer throw after append and still resolves', async () => {
|
||||
@@ -346,10 +346,10 @@ describe('ApprovalService.request', () => {
|
||||
const decided = session.events.find((event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided')
|
||||
expect(audit.map(event => event.type)).toEqual(['approval/asked', 'approval/decided'])
|
||||
expect(decided?.data).toMatchObject({ id: asked?.data.id, outcome: 'rejected' })
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('approval/decided observer threw'))
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('session/event listener threw: Error: observer failed after decided append'))
|
||||
})
|
||||
|
||||
it('does not misclassify a pre-append failure as an observer failure', async () => {
|
||||
it('propagates an append failure that prevented audit log growth', async () => {
|
||||
const ctx = await mounted()
|
||||
const failure = new Error('append failed before log growth')
|
||||
const agent = {
|
||||
|
||||
@@ -253,6 +253,12 @@ const DYNAMIC_EVENT_DISPATCHERS: Array<{ event: string; pkg: string; method: str
|
||||
// and contains each listener directly rather than rebuilding via agentEvents.
|
||||
{ event: 'agent/disposed', pkg: 'agent', method: 'events.dispatch' },
|
||||
{ event: 'session/created', pkg: 'session', method: 'events.dispatch' },
|
||||
// Session event callbacks are likewise resolved before the log push, then
|
||||
// invoked individually after commit so observer failures are contained.
|
||||
{ event: 'session/event', pkg: 'session', method: 'events.dispatch' },
|
||||
// Flush resolves the scoped callback set directly so internal instrumentation
|
||||
// cannot substitute the accepted session before parallel invocation.
|
||||
{ event: 'session/flush', pkg: 'session', method: 'events.dispatch' },
|
||||
// Session disposal uses direct callback resolution so teardown contains each
|
||||
// synchronous throw and returned-promise rejection independently.
|
||||
{ event: 'session/disposed', pkg: 'session', method: 'events.dispatch' },
|
||||
@@ -278,6 +284,12 @@ const DYNAMIC_EVENT_DISPATCHERS: Array<{ event: string; pkg: string; method: str
|
||||
{ event: 'workflow/end', pkg: 'workflow', method: 'events.dispatch' },
|
||||
]
|
||||
|
||||
const DYNAMIC_EVENT_LISTENERS: Array<{ event: string; pkg: string }> = [
|
||||
// The invariants oracle marks the session started from its global
|
||||
// internal/dispatch listener before product session-start callbacks run.
|
||||
{ event: 'agent/session-start', pkg: 'invariants' },
|
||||
]
|
||||
|
||||
function generatedHeader(title: string): string[] {
|
||||
return [
|
||||
'<!-- Generated by scripts/gen-doc-graphs.ts - do not edit by hand.',
|
||||
@@ -596,6 +608,9 @@ function collectEventRelations(): Map<string, EventRelation> {
|
||||
methods.add(entry.method)
|
||||
relation.dispatchers.set(entry.pkg, methods)
|
||||
}
|
||||
for (const entry of DYNAMIC_EVENT_LISTENERS) {
|
||||
ensure(entry.event).listeners.add(entry.pkg)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user