Files
vllm-omni/docs/design/fullduplex.md
T

1071 lines
58 KiB
Markdown

# MiniCPM-o 4.5 Full-Duplex Runtime Architecture
> Relocation note: after the H20 validation recorded below, the full-duplex
> implementation moved out of `vllm_omni/experimental/fullduplex/` into the
> stable tree (`vllm_omni/engine/duplex/`, `vllm_omni/entrypoints/duplex/`,
> `vllm_omni/model_executor/models/minicpmo_4_5/duplex/`, and companion
> modules). Module paths in this document reflect the new layout; the runtime
> evidence was produced on the pre-move tree, which is file-identical apart
> from module paths.
## Purpose
This document records the runtime architecture implemented by PR #3907, the
cleanup applied after published commit `e011d936`, and the remaining work that
must not be advertised as complete. Implemented contracts and future
architecture work are called out separately below.
The checkpoint preserves the active runtime path already exercised on H20. It does
not introduce another reducer, controller, worker provider, or shadow runtime.
## Review Snapshot
- PR: `vllm-project/vllm-omni#3907`
- Published head before this refactor: `e011d936`
- Published base snapshot: `62589203`
- Published diff: 93 files, approximately `+29.2k/-0.6k`
- Local runtime and pytest validation: intentionally not used
- Required validation environment: an NVIDIA H20 CUDA host
- PR head reviewed for the current cleanup:
`f4f78fa555af4d65e46b003022d352ca2ea9d703`
- Validated tree: the uncommitted refactor synchronized file-for-file to the
isolated H20 worktree on 2026-07-20
The current tree has received fresh H20 focused and E2E validation against vLLM
0.25.0. The final affected matrix includes the pre-response continuation and
Stage1 CUDA Graph padding regressions. The checked-in MiniCPM profile admits
two concurrent sessions through an engine-owned limit; a third
session was rejected and capacity returned after one session closed. The two
accepted sessions independently completed their audio, transcript, response,
playback, and close lifecycles. This is isolation and admission evidence for the
tested two-session deployment, not a production fairness, arbitrary-capacity,
or failure-recovery claim. The validated paths include the MiniCPM duplex
runtime extension, typed resumable policy, extracted control plane and clients, request
preregistration, typed direct-output decision, engine-managed two-session
capacity in the checked-in deploy profile, separate public/runtime configuration
channels, private Session ledgers, ordered `session.update`, the MiniCPM runner
fast path, continuous
browser input, and the final Realtime input-lifecycle fixes. Exact evidence and
scope limits are recorded below.
## Scope
The checkpoint keeps these verified contracts:
- MiniCPM Stage0 conversation KV continuity;
- Stage1 TTS and Token2Wav continuity;
- model-owned listen/speak decisions on the normal auto-response path;
- continuous browser PCM upload during assistant playback, without browser VAD
or browser-generated input commits;
- segment EOS and turn EOS as different boundaries;
- transcript/audio cursors scoped to a response and turn;
- playback acknowledgement and history commit;
- Stage0 request metadata routed per batch row rather than inherited from the
first request in the batch;
- Stage0 token timing snapshots and client-observed audio chunk cadence;
- scheduler data-plane append over a resumable request;
- stale epoch/turn/response fencing;
- `/v1/duplex` and OpenAI Realtime projection;
- optional OpenAI `server_vad` turn detection backed by a process-shared Silero
model and per-session VAD state;
- existing JoyVL behavior.
The checkpoint does not claim:
- scheduler-native KV append;
- an acoustic-onset-to-cancel latency target across arbitrary client chunk sizes;
- production multi-session admission, fairness, capacity, or failure recovery;
- bounded long-session KV;
- audio/video synchronization beyond one camera frame per model unit (camera
frames ride the audio appends as `video_frames`; the wire contract is in the
[Realtime Duplex API](../serving/realtime_duplex_api.md) guide).
## Active Runtime Path
The actual path is:
```text
WebSocket
-> OmniDuplexSessionHandler
-> DuplexSession + MiniCPMO45ServingSessionState
-> DuplexRequestClient
-> AsyncOmni thin open/append/signal/close proxies
-> DuplexControlClient over the engine-owned correlated RPC transport
-> DuplexControlPlane + DuplexSessionRuntimeManager
-> MiniCPM DuplexRuntimeExtension + engine session/stage bindings
-> StagePool
-> resumable scheduler request
-> MiniCPM Stage0
-> Stage1 TTS / Token2Wav
-> output processor
-> Realtime protocol projection
-> WebSocket writer
```
The removed typed reducer/facade and worker-provider runtimes were not in this
path. This refactor continues to modify only the path above.
## State Ownership
The architecture allows several state objects only when their facts are
orthogonal.
| Owner | Authoritative facts |
| --- | --- |
| `DuplexSession` | session state, epoch, turn, active/last response, pending input, overlap duration, playback cursor, history |
| `MiniCPMO45ServingSessionState` | MiniCPM PCM buffer, deferred overlap payload, data-plane task, continuation effect counter |
| `DuplexWebSocketActor` | inbound mailbox, outbound queue, writer, transport close state, effect task handles |
| `NativeRealtimeSessionProtocol` | wire-only response/item projection and input-buffer projection |
| `DuplexSessionRuntimeManager` | fence snapshot, append reservation, completed-operation cache, resource reservations, stage bindings |
| `DuplexControlPlane` | control-message validation, transactional open/append/update/close, extension invocation, typed segment/output decisions |
Important distinctions:
- Realtime response IDs are projection caches, not domain decision sources.
- Engine fences duplicate identity across a process boundary by design; they
are immutable validation snapshots.
- Stage bindings are engine resources, not serving response state.
- `continuation_owner_id` scopes an effect counter either to a visible response
or, before `response.created`, to an explicit model turn. A pre-response
continuation also revalidates request, epoch, turn state, and model-turn
identity immediately before append; it cannot survive turn completion.
- runtime open/close acknowledgements are handler-local effect bookkeeping,
not Session lifecycle.
## Implemented Concurrency Contracts
### Single inbound mailbox
The Actor now uses one:
```python
asyncio.Queue[event]
```
This replaces competing control/input/event `Queue.get()` tasks and the
deferred-event list while preserving WebSocket arrival order. A later close,
cancel, or clear must not overtake earlier append, playback ACK, or
session-update events. Slow runtime appends remain background tasks. Cancelling
their asyncio task does not stop a synchronous callable already running in the
default executor, so cancellation correctness is provided by the engine fence,
not by `Task.cancel()`.
Properties covered by focused tests:
- every enqueued event is delivered exactly once;
- terminal control preserves wire order while cancelling background work;
- clear and cancel never scan or delete later mailbox events;
- one writer owns all WebSocket sends.
### Correlated RPC result router
`AsyncOmniEngine` now has one consumer for `rpc_output_queue`. Results route by:
```text
("duplex", control_id)
("collective", rpc_id)
```
The router replaces the global RPC lock. It supports out-of-order replies,
timeout unregister, late-result rejection, fatal-error broadcast, and close
unblocking.
Additional lifecycle rules:
- uncorrelated non-fatal errors are not broadcast to unrelated waiters;
- a latched fatal rejects new calls before they enter the request queue;
- `EngineDeadError` is broadcast directly to the RPC queue even though the
orchestrator consumes that exception during teardown;
- shutdown closes the router before joining the orchestrator;
- failure to enqueue shutdown closes the request queue and still attempts a
bounded orchestrator join;
- `StageRuntime` cleanup runs only after the orchestrator has stopped, avoiding
concurrent client teardown.
The router removes wait-side head-of-line blocking. The orchestrator request
handler remains ordered, but executor submission order is not itself a session
ordering guarantee.
### Extracted control ownership
The generic engine mechanism and the duplex plugin are split by
responsibility:
| Module | Responsibility |
| --- | --- |
| `engine.messages` | ordinary engine queue messages and collective RPC envelopes |
| `engine.rpc_result_router` | generic correlation routing through each result message's `rpc_correlation_key` |
| `engine.duplex.contracts` | immutable duplex DTOs, extension/stage protocols, typed decisions, request-ID codec |
| `engine.duplex.lease` | duplex lease activity, TTL/grace configuration, and expiry records |
| `engine.duplex.messages` | identity fence and duplex control message/result envelopes |
| `engine.duplex.runtime` | extension loading, validation, and compatibility exports |
| `engine.duplex.session` | session resources, fences, append reservations, idempotency cache, binding cleanup |
| `engine.duplex.control_plane` | control algorithms and the narrow `DuplexStagePort` used to submit and clean up stage requests |
| `engine.duplex.control_client` | typed control-message construction and calls through the engine-owned correlated RPC transport |
| `entrypoints.duplex_request_client` | deterministic request identity, client-state preregistration, acknowledgement validation, data-plane collection, rollback |
`Orchestrator`, `AsyncOmniEngine`, and `AsyncOmni` retain explicit wiring and
compatibility proxies. They no longer own the corresponding duplex algorithms.
The ControlClient does not own waiters or consume the result queue;
`RpcResultRouter` remains the single correlation owner for both duplex and
collective RPC. The ControlPlane receives a narrow stage port rather than the
Orchestrator object.
Control-plane enablement is independent from extension presence.
`PipelineConfig.duplex_control_enabled` determines whether an Orchestrator
constructs a ControlPlane. This preserves extension-free
`TURN_COMMIT_ONLY` deployments while allowing ordinary deployments to use a
single `None` fast-path check. The MiniCPM pipeline explicitly enables control
and separately selects its runtime extension.
### Transactional open and append
Opening a native session registers its engine state and reserves the Stage0
request resource as one externally atomic operation. If capability, sampling,
extension, or request-state initialization fails, the ControlPlane removes the
session, reserved request resources, and any preregistered request state before
returning the error. Cleanup distinguishes a reserved resource from a submitted
request, so it does not decrement the running counter for unrelated work.
Closing a session that never appended also removes the preregistered resource.
Expiry and request-triggered cleanup use a two-step close. The runtime manager
first marks the session closing but retains it in the admission set. Only a
successful stage cleanup finalizes and removes the session. A blocked or failed
cleanup therefore continues to consume its server-owned slot and is retried;
replacement admission cannot overlap request or KV resources that are still
owned by the closing session.
Append uses prepare/submit/commit ordering. `prepare_append()` computes the
next sequence values without mutating the session. Prompt planning and stage
submission use that reservation, and only a successful submit commits
`input_seq`, `input_turn_seq`, and the accepted fence. Serving uses a matching
PCM reservation and consumes bytes only after the append acknowledgement;
failure or a closed predecessor rolls the bytes back. An `operation_id` caches
completed append results so a retry cannot submit the same physical append
twice.
### Ordered append and configuration effects
Native append effects form a per-session tail. Each append awaits its
predecessor before entering the engine, and `session.update` awaits that same
tail before changing either the serving aggregate or engine configuration.
Consequently, an append received before an update uses the old immutable
sampling snapshot, while the first accepted append after the update uses
the new generation.
The engine increments `config_generation` when it accepts the replacement
configuration. At the next append boundary, the control plane rebuilds sampling
parameters from stage defaults through the model extension. It never mutates
the snapshot of a segment already in progress.
### Irreversible cancellation fence
Every cancel control carries the cancelled fence and the next session fence.
The `AsyncOmni` facade preserves both values across the serving,
entrypoint, engine-message, and orchestrator boundaries. The orchestrator then
atomically:
1. advances the runtime state to the next fence;
2. releases and aborts bindings owned by the cancelled fence;
3. permanently rejects an append that arrives later with the old fence.
Missing `next_fence` is rejected without releasing bindings; the engine never
advances independently from serving. A late cancel cannot move a session
backward when the runtime is already on a newer fence. Idle playback clear does
not send an engine cancel because no runtime resource is being cancelled.
`model_native_duplex` also validates its engine-client contract before opening
the session. The open, append, signal, and close methods must exist and declare
the required `fence` arguments; signal must additionally accept `next_fence`.
An older or custom client that cannot preserve this boundary fails with
`runtime_contract_invalid` instead of silently running an unfenced native
session. The generic serving-session adapter retains its compatibility behavior.
`DuplexFence` also carries a server-side session incarnation. Reusing a public
session ID after close increments that incarnation, and resource request IDs
include it after the first incarnation. A synchronous append from a closed
session therefore cannot be accepted by a newly opened session with the same
public ID. Stale close validates the full fence before removing the runtime
registry entry or releasing bindings.
## Implemented Serving Ownership
### Session-owned response and overlap state
The Actor no longer stores:
- a Session reference;
- lifecycle mirror state;
- active or last response ID;
- overlap duration;
- response-in-progress or playback business predicates;
- runtime open/close acknowledgements.
`DuplexSession.begin_response()` owns both active and last response identity.
The Session owns overlap accumulation/reset and playback state.
One `native_response_in_progress()` predicate now covers:
- active response ID;
- active engine request ID;
- uncommitted ACK-only playback;
- active generic response task;
- response-bound append tasks;
- MiniCPM data-plane task;
- assistant-generating turn state.
The write-only `MiniCPMO45ServingSessionState.response_emitted` field and a dead
data-plane task mirror were removed.
### Response-scoped options
`response.create` is represented by an immutable `ResponseCreateOptions` value.
The Session reserves it for one response, applies it to a response-local copy
when that response begins, and restores the base session configuration on
completion, listen, cancellation, or failure. Instructions, voice, modalities,
temperature, token limit, format, tools, and conversation metadata therefore do
not mutate the permanent session configuration or leak into a later response.
### Explicit event effects and pure writer
Business paths call `emit_event()`. It serializes domain effects and outbound
queue insertion with one session-local lock. Deferred append starts outside the
lock so recursive event emission cannot deadlock.
The writer only:
1. dequeues an event;
2. applies late stale filtering to streaming model output;
3. projects the event to the selected wire protocol;
4. sends JSON.
The writer does not promote overlap, clear input, advance Session state, call
the engine, or commit history.
### Single terminal acceptance point
Terminal events are accepted or rejected before domain effects run:
```text
response.done
response.listen
audio.cancelled
input.cancelled
session.closed
```
For a terminal carrying an epoch, that epoch must match the current Session.
Stale cancellation therefore cannot reset overlap belonging to a later turn.
Once a terminal is accepted and its effect is applied, the writer cannot revoke
it because close or epoch state changed while it waited in the output queue.
Streaming audio/text deltas still receive late stale filtering at send time,
which prevents queued old audio from leaking after a cancel or epoch bump.
## Runtime Type Boundary
The stable kernel is deliberately limited to generic engine and scheduler
mechanisms:
```text
vllm_omni.engine.messages # ordinary queue and collective RPC envelopes
vllm_omni.engine.rpc_result_router # generic correlation routing
vllm_omni.engine.async_engine_utils # ordinary engine lifecycle helpers
```
The full-duplex implementation lives in dedicated duplex modules:
```text
vllm_omni.engine.duplex.runtime
# extension loading and validation
vllm_omni.engine.duplex.contracts
# immutable DTOs and narrow protocols
vllm_omni.engine.duplex.lease
# duplex lease primitives
vllm_omni.engine.duplex.messages
# duplex identity and queue envelopes
vllm_omni.engine.duplex.session
# session transaction implementation
vllm_omni.engine.duplex.control_plane
# engine-side control algorithms
vllm_omni.engine.duplex.control_client
# correlated control client
vllm_omni.entrypoints.duplex_request_client
# entrypoint request/output lifecycle
```
Stable engine and entrypoint modules import the duplex kernel eagerly at
module load (top-of-file imports). Duplex behavior remains opt-in: the
ControlPlane, ControlClient, and request client are *constructed* only on
duplex-enabled deployments, and the model runtime extension still loads only
through the dotted-string plugin path. The client-side package
(`vllm_omni/clients/`, home of the public `DuplexClient`) is never imported
by runtime code.
`PipelineConfig.duplex_control_enabled` enables the generic mechanism, while
`PipelineConfig.duplex_runtime_extension` separately selects the model adapter.
The MiniCPM pipeline opts into both and installs
`MiniCPMO45DuplexRuntimeExtension`; ordinary pipelines construct neither a
ControlPlane nor a ControlClient. The model-neutral protocol exposes
sampling-parameter configuration, append planning, and a typed stage-output
decision. MiniCPM owns
stage-specific overrides, PCM-to-token budgeting, force-listen payload policy,
special-token interpretation, and `listen` response metadata. The generic
Orchestrator does not parse MiniCPM session keys, `listen_token_id`, or construct
MiniCPM metadata, and it does not import the adapter.
Extension loading is fail-fast. Startup validates every required callable and
the number and type of returned stage sampling parameters. Invalid custom
extensions fail before a session can open.
Direct listen/speak outcomes use `DuplexOutputDecision` on
`OmniRequestOutput`. That typed envelope is authoritative over inner completion
metadata for native projection, so a processed output cannot hide a listen
decision merely because it retains unrelated multimodal metadata.
Generic multimodal chunk accumulation lives in
`vllm_omni.outputs.multimodal_accumulation`. It owns snapshot replacement,
DELTA draining, and audio-finality interpretation; `OmniRequestState` only
invokes that policy while assembling request output. Model output processors no
longer need to add chunk-retention rules to the main output orchestration file.
The strict chunk-transfer schema still contains `hidden_states.tts` as the
generic AR-stage-to-TTS handoff and several MiniCPM streaming token IDs as
compatibility wire fields. The latter are not a preferred generic model
contract. Moving them behind a model-namespaced metadata envelope requires a
versioned producer/consumer migration because unknown msgspec fields are
rejected; silently deleting or renaming them in this checkpoint would break
existing Stage0-to-Stage1 transfer.
### Public and runtime configuration channels
Control messages carry two independent snapshots:
- `session_config` contains the public Realtime/session values used by serving
and model prompt policy;
- `runtime_config` contains server-derived MiniCPM sampling, scheduler, context
reserve, prefix-budget, and resolved reference-audio values.
The MiniCPM serving adapter derives `runtime_config` from deploy/model defaults
and validated public options. Clients cannot provide private runtime keys in
`session.create` or `session.update`; response-scoped `extra_body` drops those
keys rather than turning them into engine policy. The ControlPlane constructs
sampling parameters only from `runtime_config`,
while append planning receives both snapshots explicitly. Raw Realtime
`extra_body` is not used as the engine's sampling-policy channel.
`session.update` prepares both candidates, validates the runtime candidate in
the engine, and replaces the serving snapshots only after the engine ACK. A
rejected candidate therefore leaves both the public configuration and runtime
sampling generation unchanged.
`DeployConfig.duplex_session` is an immutable, server-owned
`DuplexSessionRuntimeConfig`. These limits are not read from client
`extra_body` or session payloads:
| Field | Default | Owner | Lifecycle or resource effect |
| --- | ---: | --- | --- |
| `idle_ttl_s` | `300.0` | Engine lease manager | Expires an attached or detached session after inactivity; `null` disables only idle expiry. |
| `disconnect_grace_s` | `30.0` | Engine lease manager and API attachment registry | Bounds how long a detached session may retain engine resources before cleanup. |
| `reaper_interval_s` | `5.0` | Orchestrator | Sets the cadence for lease expiry and pending cleanup retries. |
| `resume_replay_ttl_s` | `60.0` | API attachment registry | Expires replay events retained for resume or takeover. |
| `resume_replay_max_bytes_per_session` | `8388608` | API attachment registry | Bounds each session's replay buffer. |
| `max_pending_input_bytes_per_session` | `16777216` | API session input ledger | Applies per-session byte backpressure before input is admitted. |
| `max_pending_turns_per_session` | `4` | API session input ledger | Bounds queued turns that have not completed runtime processing. |
| `max_sessions` | `1` | Engine session manager | Atomically enforces admission; closing sessions retain capacity until cleanup finalizes. |
| `completed_append_cache_size` | `256` | Engine session manager | Bounds completed append idempotency records per session. |
Deploy profiles may lower or raise these values to match scheduler capacity,
but clients cannot override them. The engine remains authoritative for
admission and cleanup ownership even when the API performs an early check.
### Client-side companion package
The public client surface ships under `vllm_omni/clients/` and stays on the
consumer side of the boundary above: `vllm_omni.clients.duplex` provides the
model-agnostic `DuplexClient` (typed events, per-response demux via
`ResponseHandle`, `session.resume` with acknowledgement-based replay dedup)
and the `EventCollector` helper used for timing and request-metric summaries.
Model-specific session defaults live in thin per-model preset modules —
`vllm_omni.clients.minicpmo_4_5.create_duplex_session_config()` and
`vllm_omni.clients.personaplex.create_duplex_session_config()` — so the core
client never imports model packages and the model duplex plugin packages
never import the client (both directions are enforced by tests:
`tests/engine/test_duplex_import_boundary.py`,
`tests/entrypoints/openai_api/test_duplex_capability.py`, and the subprocess
purity guards in `tests/clients/test_model_session_configs.py`).
`examples/online_serving/barge_in_client.py` (with its flow companion
`barge_in_client_flow.md`) is the runnable reference for the client library
against a live duplex deployment. The wire vocabulary the client speaks —
every `/v1/realtime?duplex=1` event with its OpenAI-compatibility tier and a
JSON example, plus the native-to-Realtime name map — is catalogued in the
[Realtime Duplex API](../serving/realtime_duplex_api.md) serving guide; that
page derives from the normative contract table above and must be updated
with it.
## Generic-Path Cleanup
Model-specific `MINICPMO45_PROFILE_LOGS` probes were removed from:
- the generic AR scheduler;
- orchestrator;
- AsyncOmni;
- the generic GPU AR runner.
The cleanup removes the temporary serving and generic-runtime probes. The
generic AR runner builds `DuplexSamplingRow` values only when the model exposes
the optional `prepare_duplex_sampling()` hook, then invokes that hook exactly
once before the normal sampler. Ordinary models do not scan request rows.
MiniCPM owns force-listen logits, turn-ended state, and its row-local sampling
policy behind that hook; the runner no longer reads `_minicpmo45_*` attributes
or retries sampling after matching `TypeError` text.
The scheduler no longer parses serving `session_config`, `extra_body`, or
`duplex_stage_sampling_params`. Stage-specific overrides are materialized as
ordinary `SamplingParams` by the model runtime extension before scheduler
admission. Existing vLLM stop-token, EOS, and max-token handling defines each
segment boundary; `resumable=True` parks the request for the next streaming
update. The scheduler does not inspect a duplex dictionary, Realtime
configuration, or MiniCPM sampling fields.
The generic runner's unused `_duplex_force_listen_applied_segments` cleanup and
the obsolete `streaming_accumulated_keys` accumulation hook were removed. The
latter had no model producer after its upstream Qwen removal; streaming input
still performs the ordinary replacement merge needed by active models.
Local `turn.signal` transitions such as `user_started` and
`assistant_started` no longer perform an engine RPC. Only cancellation and
`session.update` cross the engine boundary because those operations change
runtime resource identity or runtime configuration.
During a stage transition, `StreamingInputState.source_token_decoder` exposes a
short-lived, model-neutral decoder sourced from the upstream output processor.
The MiniCPM Stage1 input processor consumes that capability for token-faithful
delta transcripts. The generic orchestrator no longer installs or names a
MiniCPM-specific bridge-state key.
The stable API server imports the duplex serving package eagerly and inspects
the deployment `session_mode` before constructing the duplex handler. Ordinary
deployments do not create its registries and background state.
For an enabled deployment, the client still selects the Realtime duplex route
with `?duplex=1` (or an equivalent explicit true value). Model-name matching is
not used for routing or native-runtime activation. MiniCPM clients explicitly
set `extra_body.native_duplex=true`; the canonical way to do so is
the `vllm_omni.clients.minicpmo_4_5.create_duplex_session_config()` preset,
which the repository's barge-in example
(`examples/online_serving/barge_in_client.py`) uses in its session payload.
`DuplexRequestClient` derives the data-plane request identity from the accepted
fence and preregisters a resumable `ClientRequestState` before the append crosses
the engine boundary. The acknowledgement must return the same identity or the
operation fails. Unregistered outputs, including names that merely use the old
`duplex-` prefix, are dropped. Timeout, cancel, and close remove the
preregistered state.
## Why Scheduler Changes Remain
Serving cannot preserve model KV after a segment ends. The scheduler must keep
one resumable request in a waiting state and accept a later update:
```text
RUNNING
-> segment stop
WAITING_FOR_STREAMING_REQ (KV retained)
-> next append
RUNNING
-> session close
FINISHED (KV released)
```
Scheduler responsibilities are limited to resumable request state, runtime
context update, stop/boundary handling, and final release. Scheduler must not
own response IDs, playback, overlap, Realtime events, or model policy.
## Serving and Compatibility Boundaries
### Normative Realtime event contract
The public contract in this section applies to `/v1/realtime?duplex=1`. Internal
duplex event names such as `response.output_audio.delta` are projection inputs,
not additional public aliases. Events for one attachment preserve mailbox
order; replay after resume preserves the original event order and `event_id`.
Accepted compatibility aliases are normalized before mailbox admission:
| Canonical input event | Accepted aliases | Contract |
| --- | --- | --- |
| `input.text.append` | `input_text.append`, `push_text` | Appends text to the open input item. |
| `input_audio_buffer.append` | `input.audio.append`, `push_chunk` | Appends audio; legacy aliases default to WAV when no format is supplied. |
| `turn.signal` | `signal_turn` | Sends an explicit turn signal; it does not imply response creation. |
| `playback.ack` | `audio.playback_ack` | Advances playback/history only for the identified response and attachment. |
| `session.close` | `close_session`, `close` | Closes the session after previously admitted mailbox events. |
The Realtime projector emits the following response lifecycle. Cardinality is
scoped to one `response_id` unless stated otherwise.
| Public event | Trigger and ordering | Cardinality |
| --- | --- | --- |
| `response.created` | First event for a visible assistant response; precedes its output-item, content-part, speak, text, and audio events. | Exactly once for every visible response. |
| `response.speak` | The model selected speak; emitted after `response.created` and no later than the first `response.audio.delta`. It carries decision metadata, not transcript text. | At most once. |
| `response.audio.delta` | Carries one ordered audio chunk. Every emitted audio delta is followed by a `response.audio_transcript.delta` for the same chunk. | Zero or more. |
| `response.audio_transcript.delta` | Append-only transcript contribution paired with an audio delta; it may be empty for a text-less audio unit and must not repeat or overlap earlier text. | One per audio delta. |
| `response.audio.done` | Closes the audio stream after its final delta and before response terminal events. It is omitted for a response with no audio or transcript. | At most once. |
| `response.audio_transcript.done` | Contains the exact concatenation of all non-overlapping transcript deltas for the response. | At most once, and only for a non-empty transcript. |
| `response.output_item.done` / `conversation.item.done` | Finalize the assistant item after content-part terminal events and before `response.done`. | At most once each. |
| `response.done` | Terminal event for a created response; follows audio, transcript, content-part, and item terminal events. | Exactly once for every created response that reaches a terminal state. |
| `rate_limits.updated` | Compatibility event emitted immediately after `response.done`; the current payload has an empty rate-limit list. | Once per emitted `response.done`. |
| `response.listen` | The model selected listen at a model-unit boundary. It may be emitted without `response.created`; clients must not infer a missing or pending response from it. | Zero or more per session, at most once per model-unit decision. |
Session lifecycle events (`session.created`, `session.updated`,
`session.resumed`, `session.replaced`, `session.expired`, and
`session.resync_required`) are session-scoped rather than response-scoped.
`session.created` is emitted before held response output on a new attachment.
Cancellation and truncation use terminal status/details on the affected
response and never reuse that response ID for later model turns.
Compatibility changes must update this table and its protocol contract tests in
the same change. The golden transcript test requires joined
`response.audio_transcript.delta` values to equal
`response.audio_transcript.done.transcript`; response tests separately enforce
at-most-once `response.speak` and terminal event cardinality.
### Session internal ledgers
`DuplexSession` remains the serving aggregate root and now composes
`InputBufferState`, `ResponseState`, `PlaybackLedger`, and
`ConversationHistory`. The ledgers are private. Read-only properties return
tuples, snapshots, or mapping proxies, and serving modules mutate them through
Session transition methods for request, response-turn, lifecycle, playback,
capability replacement, and history. The Realtime session-update parser still
normalizes individual fields before atomically retaining or rolling back the
aggregate configuration. Further work may narrow the read-only compatibility
surface, but it must not introduce another reducer or a second session state
machine.
### Realtime input ownership
The browser is a continuous PCM producer. While the session is open and the
microphone is unmuted, it sends `input_audio_buffer.append` every 200 ms,
including while assistant audio is being generated or played. It does not run
VAD and does not send `input_audio_buffer.commit`. Native Stage0 consumes the
stream in approximately one-second model units; MiniCPM listen, speak, and turn
EOS decisions advance the model conversation.
Explicit Realtime clients may still use `input_audio_buffer.commit` to create a
conversation item. The translator validates that wire input before producing a
commit carrying `item_id`. Native auto-response may already have
streamed those PCM samples into the runtime, so the runner accepts a validated
commit even when no runtime-side chunk remains. A truly empty explicit buffer
continues to return `input_audio_buffer_empty`.
The commit-to-response contract is:
| Session state at `input_audio_buffer.commit` | Runtime action | Required event outcome |
| --- | --- | --- |
| Auto-response disabled, valid idle input | Commit and retain the input item. | Emit `input_audio_buffer.committed`; emit no response until an explicit `response.create`. |
| Auto-response enabled, valid idle speech | Submit the final native append for the current model turn. | Emit `input_audio_buffer.committed` before model output. Create a response only on the first visible text/audio output; a model listen decision may end with `response.listen` and no response. |
| Auto-response enabled, short overlap during an active response | Discard the short overlap as an acknowledgement. | Emit the committed/no-response acknowledgement; do not create or defer a response. |
| Auto-response enabled, meaningful overlap during an active response | Retain one deferred payload and promote it after the active response terminates. | Emit `input_audio_buffer.committed` with deferred metadata. A later response follows the same visible-output rule and never reuses the prior response ID. |
| Explicit silence/noise (`is_speech=false`) | Clear the physical input without a runtime append. | Emit the committed/no-response acknowledgement followed by `response.listen`. |
| Empty or invalid input | Reject without changing response ownership. | Emit one typed `error`; do not emit `response.created`. |
| Previous response cancelled or truncated and cleanup complete | Apply the corresponding idle rule above. | The prior response remains terminal; any later visible output receives a new response ID. |
Append tasks, explicit commits, cancellation, and deferred promotion are
serialized by the session actor. Tests cover post-response commits, active
playback deferral, truncated-response ownership, and two-turn
response-required execution; the H20 validation record below is the golden
multi-turn runtime evidence.
During auto-response overlap, `preserve_realtime_input` distinguishes "do not
append this silent chunk to the native buffer" from "clear the open Realtime
item". Silent overlap no longer discards earlier user PCM.
The first chunk of one overlapping input item also reserves its target model
turn. A later Realtime commit uses that reserved identity even if response EOS
has already advanced the Session turn; clear, cancel, close, and successful
promotion release the reservation.
### Physical input and model-turn identity
A Realtime input item, an optional `input_audio_buffer.commit`, and a MiniCPM
model turn are intentionally different identities. The browser normally keeps
one input stream open without commits. Native Stage0 evaluates that stream in
approximately one-second model units. A sampled `<|turn_eos|>` closes the
current model turn and allows a later streamed unit to start another turn.
Explicitly committed input may also produce only model-listen decisions and no
spoken response.
Therefore neither `model_turn_id` nor response cardinality is derived from the
number of physical inputs. `model_turn_id` advances at model EOS. The
`response-required` fixture may require exactly one response per requested
turn, but `model-policy` validation must accept additional model-owned responses
and cannot assert cross-turn transcript independence through physical-input
attribution.
Before closing, the model-policy demo drains every created response through
`response.done`, acknowledges every completed audio playback, and requires a
bounded quiet interval with no newly created response. This validates lifecycle
completion without suppressing or overriding the model's listen/speak policy.
### Non-cancel signal semantics
`turn.signal` remains a public compatibility surface for local Session and
Realtime transitions. Serving forwards only cancellation and `session.update`
to the engine. The engine rejects arbitrary non-cancel signal names instead of
returning a misleading supported acknowledgement. `session.update` remains a
real engine operation because it replaces the configuration consumed by later
append plans.
## Remaining Architecture Work
The following work is deliberately not hidden inside this checkpoint.
### Serving composition
The generic runner and runtime bridge now depend on the model-neutral
`ServingRuntimeAdapter` protocol and no longer import MiniCPM modules. The
`MiniCPMO45ServingRuntimeAdapter` owns MiniCPM serving state, PCM preparation,
data-plane projection, client/runtime configuration validation, prefix policy,
and capability projection. `PipelineConfig.duplex_serving_adapter` explicitly
selects its import path; `AsyncOmniEngine` and `AsyncOmni` carry that path to the
API server, which passes it into the generic handler. The handler has no model
default and fails startup when the duplex endpoint is enabled without an
adapter. Tests may inject an adapter instance directly. Import-boundary tests
verify that loading the generic runner, bridge, or handler does not load MiniCPM
modules.
Behavior is still assembled through `DuplexSessionRunnerMixin`,
`NativeRuntimeBridgeMixin`, and `ChatFallbackProjectorMixin`, so explicit
adapter composition is only the model-policy boundary, not a completed removal
of host-method coupling. A later refactor may replace the remaining mixins with
explicit protocol and effect-runner dependencies while preserving one actor
mailbox and one `DuplexSession` transition owner. Splitting those components
must not create a second reducer or concurrent state machine.
The four private Session ledgers are data partitions, not independent owners.
`DuplexSession` intentionally remains the aggregate root. Further encapsulation
may narrow compatibility properties, but serving code must continue to perform
state changes through Session transitions.
### Plugin descriptor and typed payloads
The pipeline currently selects the MiniCPM engine extension and Serving adapter
through two explicit fields, but there is not yet one versioned plugin descriptor
that binds those identities in an open handshake. Realtime client payloads do
not select either implementation. Adding a second native model should first
introduce that descriptor and reject serving/engine plugin mismatches explicitly.
The stable boundary has typed fences, plans, policies, decisions, and control
messages, but `session_config`, `runtime_config`, and append payloads still use
generic mappings or objects at the extension boundary. Model-neutral typed
session and input-chunk contracts remain follow-up work, especially before
adding video.
### Output modality policy
The current Realtime duplex contract supports text and audio output. Unknown
output modalities fail closed with `unsupported_response_modality`; they are
never silently projected as text. This checkpoint deliberately does not add a
modality registry because there is only one native text/audio projection path
to register.
A registry becomes justified when a second non-audio/non-text implementation
uses the same validation, encoding, lifecycle, and projection contract. At that
point the abstraction must be extracted from two working adapters and retain
the same fail-closed behavior. A speculative registry before that condition
would add selection state without removing any existing branch.
### Performance measurement contract
Stage0 token timing comes only from the engine's per-segment metric snapshot:
`vllm_ttft_ms`, `vllm_tpot_ms`, and `vllm_itls_ms`. A resumable Stage0 request
exports its segment-local `num_generation_tokens`; ordinary requests retain the
existing output-token fallback. Realtime response state clears its metric
accumulator at `response.created`, combines only model units owned by that
response, and ignores session-level events with no matching `response_id`.
Realtime projection carries the result under
`metadata.vllm_omni.stage_metrics`; clients do not estimate TPOT from transcript
deltas or subtract cumulative snapshots.
Audio timing uses the client's monotonic receive clock and is partitioned by
`response_id`. `response_created_to_first_audio_ms` measures projection startup
after `response.created`; `commit_to_first_audio_ms` measures end-to-end output
from an explicit input commit. Neither field is called TTFP without naming its
origin. Reports also include inter-chunk and chunk-duration
count/mean/p50/p95/max plus maximum chunk gap. Engine and client measurements
have different clock origins and must not be merged into one timeline.
### Stage1 CUDA Graph boundary
Native Stage1 streaming advances Python-owned request generators and turn
state on every scheduler step. A full-model CUDA Graph would execute that
Python control flow only while capturing and then replay stale chunk and turn
metadata, even if its host-to-device tensor construction were made
capture-safe. The duplex deploy profile therefore keeps `enforce_eager=false`
but selects `cudagraph_mode=PIECEWISE` for Stage1. Python orchestration runs on
every step while fixed-topology compiled regions remain eligible for CUDA
Graph capture. This is a model execution constraint, not an E2E-only override.
### Resource capabilities
At the time of this snapshot the checked-in MiniCPM duplex deploy profile set
Stage0 and Stage1 `max_num_seqs` to two and configured the engine runtime
manager with `max_sessions=2`; the duplex settings have since been folded into
`vllm_omni/deploy/minicpmo_4_5.yaml`, which admits four sessions
(`duplex_session.max_sessions: 4`, matching its stage `max_num_seqs`). The
Realtime capability response advertises
multi-session and same-replica multi-session support with
`session_admission_mode="engine_managed"`. Client session fields cannot raise
this server-owned limit. Fresh E2E evidence covers two concurrent sessions in
both model-policy and response-required modes, rejection of a third session,
and admission of a replacement after one accepted session closes. It does not
establish behavior beyond that configured limit.
Session leases, lease TTL, resumable attachment, orphan reaping, and bounded
Serving input/backpressure are implemented. A production admission controller,
KV-aware capacity budgeting, fairness, starvation metrics, and multi-session
worker-failure recovery remain follow-up work. Capability claims must stay
limited to the mechanisms and two-session execution shape validated here.
## Validation Evidence
All pytest and runtime evidence for this branch must run on the remote H20.
### Current synchronized tree
The current dirty tree was synchronized to the isolated H20 worktree at
`/home/admin/workspace/aop_lab/model_runner_v2/vllm-omni-worktrees/pr3907-boundary-cg-0721`.
- Final affected matrix: 661 passed, one network-dependent config test
deselected, 20 warnings. It includes the complete MiniCPM native-duplex hook
file and all control/session/handler/Orchestrator/import-boundary suites. The
run is task `be221f67` (`/tmp/remote_gpu_logs/be221f67.log`). The deselected
`test_resolve_when_autodetect_resolves_none` attempted a Hugging Face model
lookup and hung in the isolated environment; the duplex deploy-config test
passed independently.
- Stage1 PIECEWISE CUDA Graph validation: the deploy-config assertion first
failed with no Stage1 compilation config, then passed in task `3ed6091d`.
Server task `95827027` started with `enforce_eager=False`, logged
`cudagraph_mode=PIECEWISE`, and captured batch sizes 1, 2, and 4 without the
CPU-to-CUDA capture error. The first concurrent E2E exposed graph padding as
`request_token_spans` covering three valid rows in a four-row capture. A
focused RED/GREEN now verifies both single-request and batched-request
padding, and the full MiniCPM hook file passed 47 tests in task `e89dba88`.
After the fix, two concurrent response-required sessions each completed two
spoken responses, playback acknowledgements, and history lifecycles with no
error or stale event. Artifacts are under
`/tmp/pr3907_piecewise_response_required_2x2_fixed_20260721`.
- An exploratory two-session, two-turn model-policy run still left one session
waiting after its second input commit while the other session completed both
turns. The same liveness symptom was reproduced with Stage1 eager mode before
the padding fix, and no Stage1 fatal occurred after the fix. This run is not
counted as PIECEWISE acceptance evidence; the remaining Stage0 resumable
reactivation path requires separate diagnosis. Artifacts are under
`/tmp/pr3907_piecewise_model_policy_2x2_fixed_20260721`.
- Latest broad affected matrix before the final pre-response continuation fix:
608 passed, 19 warnings. This includes the
Stage0 row-routing regression, cleanup-held admission regressions,
segment-local token accounting, response-owned metric filtering, the generic
output-processor suite, direct E2E script execution, and continuous-response
overlap ownership. The run is task `e2146318`
(`/tmp/remote_gpu_logs/e2146318.log`).
- The pre-response continuation regression group passed 5 focused tests. The
response-required two-turn scenario then passed 5/5 repeated runs in task
two-session semantic isolation plus resume, takeover, and
admission passed in task `49225794`; model-unit overlap passed in task
`3888387d`. The final affected matrix above supersedes the earlier pending
rerun requirement.
- Serving plugin selection was first reproduced as three RED tests: the generic
handler silently selected MiniCPM and the pipeline had no explicit Serving
adapter field. The final pipeline/import/fail-fast group passed 9 tests in task
`4db6d959` (`/tmp/remote_gpu_logs/4db6d959.log`). The token-metric and client
audio-cadence producer-to-consumer group passed 8 tests in task `cf80f6b2`
(`/tmp/remote_gpu_logs/cf80f6b2.log`).
- Stable-import isolation was first reproduced as a RED that loaded twelve
`experimental.fullduplex` modules. After extracting the kernel and making
implementation loading conditional, the isolated import regression passed.
The focused ControlPlane/ControlClient/session/runtime/Orchestrator/AsyncOmni
suite then passed 139 tests in task `fad613ea`
(`/tmp/remote_gpu_logs/fad613ea.log`).
After the final contract-import cleanup, the import-boundary, runtime, and
lease suites passed 45 tests in task `bc138887`
(`/tmp/remote_gpu_logs/bc138887.log`).
- Concurrency and failure-path hardening now has direct regressions for
single-flight cancel/close cleanup, cleanup retry without dropping request
resources, failed-replica affinity cleanup and same-address reattachment,
append ordering across resume, takeover ownership of pending-turn
reservations, and a closed-transport send immediately after resumable
credentials are delivered. The final focused resume/takeover group passed 5
tests in task `dc2ac96e` (`/tmp/remote_gpu_logs/dc2ac96e.log`).
- Stage1 batching regression: the focused test first failed because only the
first request in `runtime_additional_information` reached the talker. After
routing every request by `request_token_spans`, preserving per-row terminal
flags, and emitting request-keyed sparse audio, the complete MiniCPM duplex
hook file passed 42 tests.
- Stage0 batching regression: two requests in one batch now retain different
`duplex_prompt_token_ids` and `special_token_ids`. The model emits row-aligned
payload elements, so Stage0 no longer copies request zero's duplex metadata to
later sessions.
- Admission finalization regressions cover `max_sessions=1` with blocked and
failed expiry cleanup plus request-triggered cleanup. Every replacement is
rejected with `resource_exhausted` until stage resources are cleaned and the
old session is explicitly finalized.
- Resumable token accounting exports each model unit's native generation-token
count instead of recounting the request's cumulative token IDs. Realtime
response state resets and accumulates those segment snapshots, and the client
ignores unowned session-level metrics while summarizing a response.
- Engine-owned admission and two-session model-policy E2E: two random session
IDs were admitted and remained isolated, a third was rejected with
`resource_exhausted`, closing one accepted session returned capacity, and a
random replacement session was admitted. The capability projection reported
`configured_limit=2`, `advertised_multi_session=true`, and
`session_admission_mode=engine_managed`. In the same run one session selected
speak and the other selected listen without identity leakage. Detach/resume
advanced attachment generation from one to two and rotated its token; active
takeover rejected all four writes from the replaced connection. The run is
task `1d9dd1ed` (`/tmp/remote_gpu_logs/1d9dd1ed.log`), with artifacts under
`/tmp/pr3907_resume_takeover_native_metrics`.
- Concurrent two-session, two-turn response-required E2E: both sessions produced
two complete spoken responses, committed two playback acknowledgements and
history records, and emitted no error, cancel, truncate, or stale events. The
semantically distinct inputs produced isolated transcripts containing
"当时" and "一加一" respectively. Per-response Stage0 output counts were 20,
38, 32, and 33 tokens rather than cumulative request totals. The run is task
`a16f1467` (`/tmp/remote_gpu_logs/a16f1467.log`), with artifacts under
`/tmp/pr3907_semantic_2turn_native_metrics`.
- Disconnect-grace E2E: a detached session expired after the configured
30-second engine grace, and a later resume was rejected with
`session_resume_expired`. The run is task `9872d50c`, with artifacts under
`/tmp/pr3907_0719_disconnect_grace_expiry`.
- A separate black-box takeover client exercised the current backend with two
semantically distinct concurrent inputs. Active resume replayed 17 events
including five audio deltas and committed playback history; takeover rejected
all four stale-attachment sends, emitted `session.replaced`, and preserved
attachment/session isolation. Both transcripts contained only their expected
semantic token. The client task is `6773bd34`
(`/tmp/remote_gpu_logs/6773bd34.log`), with artifacts under
`/tmp/pr3907_gaohan_final_takeover`. The backend task was `5abcd1ee`.
- Across the final response-required, overlap, and resume/takeover runs, engine
Stage0 TTFT ranged from 31.17 to 310.18 ms, TPOT from 15.18 to 21.18 ms, and
reported ITL p95 from 16.35 to 32.83 ms. Explicit
`commit_to_first_audio_ms` ranged from 386.80 to 1033.25 ms, while the
client-monotonic maximum audio chunk gap ranged from 388.30 to 1316.37 ms.
These are response-scoped observations from a small, warm-state-sensitive
sample, not an SLO claim. The slowest response still does not satisfy a 200 ms
end-to-end first-output target.
- Post-merge focused handler/demo/Web/Stage0/runner/video suite: 324 passed,
19 warnings. The run is task `f78cb7a8`
(`/tmp/remote_gpu_logs/f78cb7a8.log`). The Web contract explicitly rejects
browser VAD markers and browser-generated input commits while the merged
video path retains camera-frame admission.
- No-response listen and residual accounting: the focused handler regressions
passed after first reproducing both failures. A model-listen unit without a
Realtime response no longer defers the next committed input, and three
consecutive 1.2-second `pcm_f32le` inputs each returned pending input bytes
to zero.
- Model-unit overlap E2E: the second physical input streamed while the first
response remained active. Seven overlap decisions had
`defer_runtime_append=false`; the one-second model-unit boundary occurred
before the first `response.done`, and no barge-in, cancel, truncate, stale, or
error event occurred. The model-policy contract accepts a later unit ending
the continuous response instead of requiring a second physical-input-owned
response. The two response-owned Stage0 snapshots independently reported 20
tokens / 152.8 ms TTFT and 19 tokens / 43.3 ms TTFT. The run is task
`a4977622` (`/tmp/remote_gpu_logs/a4977622.log`), with artifacts under
`/tmp/pr3907_overlap_native_metrics_scoped`.
- The split multi-session E2E runner is directly executable from the repository
even when another installed package owns the top-level `tests` name. Its
script-import and continuous-terminal regressions passed in task `f11b106c`.
- Pinned response-required E2E: the first 1400 ms of the pinned fixture produced
one complete spoken response, five audio/transcript deltas, and one playback
history commit, with symmetric created/audio-done/done lifecycle and no
duplicate `response.speak`. The client task is `a76dec52`
(`/tmp/remote_gpu_logs/a76dec52.log`).
- Continuous-input model-policy E2E: 36 browser-like 200 ms chunks produced
seven model-listen decisions with zero input commits and no
error/cancel/truncate/stale events. The client task is `fb256595`; artifacts
are in `/tmp/minicpmo_pr3907_continuous_model_policy_20260718`.
- Three-response continuous-input E2E: three consecutive 9469 ms streams,
composed of the original fixture plus four seconds of silence, produced three
spoken responses in one session. All three completed `response.done`, audio
transcript, playback ACK, and history commit; the run had 22 audio/transcript
deltas, zero input commits, and no error/cancel/truncate/stale events. This is
lifecycle evidence, not semantic cross-input independence, because the same
fixture was repeated. The client task is `2a5f29f0`; artifacts are in
`/tmp/minicpmo_pr3907_continuous_three_response_20260718`.
- The fresh validation backend was stopped after the E2E runs and GPU0/1 returned
to baseline. Audio-quality and ASR evaluation were not rerun for these
lifecycle-only changes. Other GPU workloads were not modified.
### Regression lineage
Earlier synchronized checkpoints passed broader affected matrices before the
extension, policy, and ledger consolidation. Those runs were useful while
developing the refactor but are superseded by the current file-identical H20
matrix and E2E evidence above; they are intentionally omitted to avoid mixing
different trees in the acceptance argument.
### Focused suites
- RPC router and AsyncOmni shutdown/error routing;
- orchestrator fatal and duplex control paths;
- WebSocket Actor mailbox and writer;
- duplex protocol and Session ownership;
- Realtime handler, terminal ordering, overlap, playback ACK;
- scheduler resumable boundary;
- MiniCPM runner and Stage0/Stage1 lifecycle.
### E2E contract
Run two deliberately separate fixture contracts:
- model-policy uses three distinct inputs, no transcript hints, 200 ms
real-time pacing, and continuous input with no browser commit. It allows
natural listen or speak decisions and assumes no 1:1 mapping between
physical inputs, model turns, and responses;
- response-required uses `minicpmo_pr3907_jiayan_16k.wav` pinned to SHA-256
`2e5fd4eb3ee434ce107ee3a0591fa624a33f7683c7462f45fe651c443c9af941`,
sends its first 1400 ms, and requires one complete spoken response.
It is an output-lifecycle fixture, not evidence that arbitrary no-hint,
real-time input must select speak;
- late and old-response playback ACK handling in the affected test matrix;
- final model-policy response drain before `session.close`;
- close/disconnect with no post-terminal audio;
- transcript delta/final consistency;
- generated WAV validity and external ASR sanity.
Distinct-input fixtures must also be semantically distinct. Different files or
waveform hashes containing the same greeting can legitimately produce the same
reply and are not sufficient evidence for cross-turn transcript independence.
For each run record the exact SHA/worktree, command, server log, client log,
WAV path, and cleanup result.
## Acceptance Criteria
This checkpoint is ready to publish only when:
- one mailbox passes exactly-once and wire-order tests;
- cancel advances the engine fence even when the old append continues in an
executor, and the next-epoch append remains accepted;
- request completion removes MiniCPM helper and model-owned force-listen state;
- RPC fatal/close wakes every waiter and no new work enters after terminal;
- Session is the only serving response/overlap authority;
- stale terminal events cannot mutate a newer epoch;
- an accepted terminal cannot be dropped by the writer;
- no duplicate `response.speak` or transcript delta appears;
- Stage0 KV continuity and Stage1 turn reset pass;
- the real H20 multi-turn audio E2E passes;
- ordinary non-duplex paths in the affected matrix still pass;
- no local path, proxy token, temporary profiling probe, or test-only switch is present.
Passing this checkpoint supports the statement:
> Single-session, model-owned MiniCPM-o 4.5 native duplex is reviewable on the
> validated H20 configuration.
It does not support claims for an acoustic-onset barge-in latency target across
arbitrary client chunk sizes, multi-session production concurrency, bounded
long-session KV, or scheduler-native append. Camera-frame input
(`video_frames`) landed after this snapshot and is covered by its own E2E
test rather than by the evidence above.