fix(session): contain post-commit observers
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user