Skip to content

Commit 1f13c36

Browse files
authored
feat(sidecar): stream extension events live during dispatch (#132)
1 parent f183ed2 commit 1f13c36

40 files changed

Lines changed: 10866 additions & 62 deletions

‎CLAUDE.md‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ Every bound that protects a shared resource — memory/heap, CPU/wall-clock, fd/
4242

4343
## Project Boundaries
4444

45-
- Keep this repo Agent OS-agnostic: no ACP, agents, sessions, `agentos-protocol`, `agentos-client`, or `agentos-sidecar` dependencies in secure-exec code.
45+
- Keep the secure-exec runtime Agent OS-agnostic: no ACP, sessions, `agentos-protocol`, `agentos-client`, or `agentos-sidecar` dependencies in runtime code.
46+
- Packaged agent definitions/adapters live in `registry/agent/*`; generic VM software lives in `registry/software/*`.
4647
- `crates/bridge/` is the browser/native portability seam. Shared contracts belong there.
4748
- `crates/vfs/` is generic filesystem infrastructure: POSIX-style in-memory/overlay/mount/root engines plus generic chunked/object engines and in-memory stores. It must stay free of secure-exec sidecar, bridge, S3, SQLite, and host-disk coupling.
4849
- `crates/secure-exec-vfs/` contains concrete secure-exec filesystem backends: S3 adapters, host-disk SQLite/file stores, and bridge/callback-backed metadata stores. Policy decisions, config validation, and mount descriptor parsing stay in sidecar plugins.
@@ -80,6 +81,12 @@ Every bound that protects a shared resource — memory/heap, CPU/wall-clock, fd/
8081

8182
## Development
8283

84+
### Release Tracks
85+
86+
- **secure-exec runtime** — `@secure-exec/*` npm packages and `secure-exec-*` crates; releases keep npm/crates in sync, previews are npm-only. See "Preview-publishing" and "Publishing" for details.
87+
- **`@agentos-software/*` registry packages** — generic VM software from secure-exec `registry/software/*` plus agent adapters from secure-exec `registry/agent/*`; versioned independently of secure-exec runtime packages.
88+
- **agent-os product/API** — `@rivet-dev/agentos*`, AgentOs APIs, sidecar wrapper, docs, quickstarts, and examples; see agent-os `CLAUDE.md` for its pinning workflow.
89+
8390
### Preview-publishing
8491

8592
Dispatch `.github/workflows/publish.yaml` (workflow_dispatch) with no version input to cut a **preview** (debug sidecar build, npm-only, dist-tag = sanitized branch name) — for handing a build to a downstream (agent-os) or external project. **Preview-publish is for previews ONLY; never cut a release with it.** Caveats: WASM-bearing packages (`@secure-exec/core`, `@agentos-software/*`) publish MANUALLY (see Publishing), and the crates.io job is skipped on preview — a *crate* change only reaches consumers locally (path dep / `[patch]`) or via a real release.
@@ -91,7 +98,7 @@ Dispatch `.github/workflows/publish.yaml` (workflow_dispatch) with no version in
9198

9299
## Publishing
93100

94-
- **The `@secure-exec/*` npm packages and the `secure-exec-*` Cargo crates are always published at the same version** (npm and crates stay in sync), so a downstream pins both to one `<v>`. The `@agentos-software/*` registry software packages are on a **separate** version track.
101+
- **The `@secure-exec/*` npm packages and the `secure-exec-*` Cargo crates are always published at the same version** (npm and crates stay in sync), so a downstream pins both to one `<v>`. See "Release Tracks" for how this differs from `@agentos-software/*` and agent-os releases.
95102
- CI (`.github/workflows/publish.yaml`) does NOT build or publish the WASM command binaries. There is no `build-commands` job and nothing restores a `wasm-commands` artifact — the workflow only builds/publishes the sidecar binary and the pure-TS packages.
96103
- WASM-bearing packages are ALWAYS published MANUALLY: `@secure-exec/core` (which vendors `registry/native` commands into `packages/core/commands` via `copy-wasm-commands.mjs`, guarded by its `prepack --require`) and the `@agentos-software/*` registry software. `@secure-exec/core` is in `EXCLUDED` in `scripts/publish/src/lib/packages.ts`, so CI never publishes it.
97104
- Manual core flow: build the commands locally (`make -C registry/native wasm`), then `npm publish` (not `pnpm publish`) `@secure-exec/core` at the **same version** CI used for that release so dependents resolving `@secure-exec/core@<version>` succeed. `prepack` vendors the commands and fails loud if they are absent.

‎crates/sidecar/src/extension.rs‎

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use crate::protocol::{
88
ProcessKilledResponse, ProcessStartedResponse, SidecarRequestPayload, SidecarResponsePayload,
99
StdinClosedResponse, StdinWrittenResponse, WriteStdinRequest,
1010
};
11-
use crate::state::{SharedSidecarRequestClient, SidecarError};
11+
use crate::state::{SharedEventSink, SharedSidecarRequestClient, SidecarError};
1212

1313
pub type ExtensionFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, SidecarError>> + 'a>>;
1414

@@ -151,6 +151,7 @@ pub struct ExtensionSnapshot {
151151
namespace: String,
152152
ownership: OwnershipScope,
153153
sidecar_requests: SharedSidecarRequestClient,
154+
event_sink: SharedEventSink,
154155
}
155156

156157
pub struct ExtensionContext<'a> {
@@ -163,11 +164,13 @@ impl ExtensionSnapshot {
163164
namespace: String,
164165
ownership: OwnershipScope,
165166
sidecar_requests: SharedSidecarRequestClient,
167+
event_sink: SharedEventSink,
166168
) -> Self {
167169
Self {
168170
namespace,
169171
ownership,
170172
sidecar_requests,
173+
event_sink,
171174
}
172175
}
173176

@@ -196,6 +199,28 @@ impl ExtensionSnapshot {
196199
crate::wire::event_frame_from_compat(self.ext_event(payload)).map_err(wire_protocol_error)
197200
}
198201

202+
/// Emit a wire event frame to the host the instant it is produced, rather
203+
/// than collecting it into the dispatch result and waiting for the whole
204+
/// request to resolve. Returns `Ok(None)` when delivered live, or
205+
/// `Ok(Some(event))` when no live sink is configured (in-process sidecar) so
206+
/// the caller can fall back to returning it in the dispatch's batch.
207+
pub fn emit_event_wire(
208+
&self,
209+
event: crate::wire::EventFrame,
210+
) -> Result<Option<crate::wire::EventFrame>, SidecarError> {
211+
self.event_sink.try_emit(event)
212+
}
213+
214+
/// Build a namespaced ext-event from `payload` and emit it live (see
215+
/// [`Self::emit_event_wire`]).
216+
pub fn emit_ext_event(
217+
&self,
218+
payload: Vec<u8>,
219+
) -> Result<Option<crate::wire::EventFrame>, SidecarError> {
220+
let event = self.ext_event_wire(payload)?;
221+
self.emit_event_wire(event)
222+
}
223+
199224
pub fn invoke_callback(
200225
&self,
201226
payload: Vec<u8>,
@@ -241,6 +266,24 @@ impl<'a> ExtensionContext<'a> {
241266
self.snapshot.ext_event_wire(payload)
242267
}
243268

269+
/// Emit a wire event frame to the host live (see
270+
/// [`ExtensionSnapshot::emit_event_wire`]).
271+
pub fn emit_event_wire(
272+
&self,
273+
event: crate::wire::EventFrame,
274+
) -> Result<Option<crate::wire::EventFrame>, SidecarError> {
275+
self.snapshot.emit_event_wire(event)
276+
}
277+
278+
/// Build a namespaced ext-event from `payload` and emit it live (see
279+
/// [`ExtensionSnapshot::emit_ext_event`]).
280+
pub fn emit_ext_event(
281+
&self,
282+
payload: Vec<u8>,
283+
) -> Result<Option<crate::wire::EventFrame>, SidecarError> {
284+
self.snapshot.emit_ext_event(payload)
285+
}
286+
244287
pub fn invoke_callback(
245288
&self,
246289
payload: Vec<u8>,
@@ -601,3 +644,84 @@ pub trait Extension: Send + Sync {
601644
Box::pin(async { Ok(()) })
602645
}
603646
}
647+
648+
#[cfg(test)]
649+
mod live_event_tests {
650+
use super::*;
651+
use crate::state::EventSinkTransport;
652+
use std::sync::{Arc, Mutex};
653+
654+
/// Records every event handed to the live sink, standing in for the stdio
655+
/// `FrameEventTransport` that writes `ProtocolFrame::EventFrame`s to stdout.
656+
#[derive(Default)]
657+
struct RecordingEventSink {
658+
events: Arc<Mutex<Vec<crate::wire::EventFrame>>>,
659+
}
660+
661+
impl EventSinkTransport for RecordingEventSink {
662+
fn emit_event(&self, event: crate::wire::EventFrame) -> Result<(), SidecarError> {
663+
self.events.lock().unwrap().push(event);
664+
Ok(())
665+
}
666+
}
667+
668+
fn snapshot_with_sink(event_sink: SharedEventSink) -> ExtensionSnapshot {
669+
ExtensionSnapshot::new(
670+
String::from("dev.rivet.test.live-event"),
671+
OwnershipScope::session("conn-live", "sess-live"),
672+
SharedSidecarRequestClient::default(),
673+
event_sink,
674+
)
675+
}
676+
677+
// With a transport configured (the stdio path), an ext event is emitted live
678+
// and `emit_ext_event` reports nothing left to batch.
679+
#[test]
680+
fn emit_ext_event_streams_live_when_sink_configured() {
681+
let recorded = Arc::new(Mutex::new(Vec::new()));
682+
let mut sink = SharedEventSink::default();
683+
sink.set_transport(Arc::new(RecordingEventSink {
684+
events: recorded.clone(),
685+
}));
686+
687+
let snapshot = snapshot_with_sink(sink);
688+
let leftover = snapshot
689+
.emit_ext_event(b"live-update".to_vec())
690+
.expect("emit must succeed");
691+
692+
assert!(
693+
leftover.is_none(),
694+
"a configured sink consumes the event live, leaving nothing to batch"
695+
);
696+
let recorded = recorded.lock().unwrap();
697+
assert_eq!(recorded.len(), 1, "the event must reach the live transport");
698+
// The live frame round-trips back to the namespaced ext payload.
699+
let compat = crate::wire::event_frame_to_compat(recorded[0].clone())
700+
.expect("recorded frame converts back to compat");
701+
match compat.payload {
702+
EventPayload::Ext(envelope) => {
703+
assert_eq!(envelope.namespace, "dev.rivet.test.live-event");
704+
assert_eq!(envelope.payload, b"live-update");
705+
}
706+
other => panic!("unexpected live event payload: {other:?}"),
707+
}
708+
}
709+
710+
// With no transport (an in-process sidecar), the event is handed back so the
711+
// caller can fall back to the dispatch-result batch — preserving delivery.
712+
#[test]
713+
fn emit_ext_event_falls_back_to_batch_without_sink() {
714+
let snapshot = snapshot_with_sink(SharedEventSink::default());
715+
let leftover = snapshot
716+
.emit_ext_event(b"batched-update".to_vec())
717+
.expect("emit must succeed");
718+
719+
let frame = leftover.expect("without a sink the event is returned for batching");
720+
let compat = crate::wire::event_frame_to_compat(frame)
721+
.expect("returned frame converts back to compat");
722+
match compat.payload {
723+
EventPayload::Ext(envelope) => assert_eq!(envelope.payload, b"batched-update"),
724+
other => panic!("unexpected batched event payload: {other:?}"),
725+
}
726+
}
727+
}

‎crates/sidecar/src/lib.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ pub use extension::{
2828
ExtensionInterruptResponse, ExtensionResponse,
2929
};
3030
pub use service::{DispatchResult, NativeSidecar, NativeSidecarConfig, SidecarError};
31+
pub use state::EventSinkTransport;
3132
pub use state::SidecarRequestTransport;
3233

3334
use wire::{DEFAULT_MAX_FRAME_BYTES, PROTOCOL_NAME, PROTOCOL_VERSION};

‎crates/sidecar/src/service.rs‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ use crate::protocol::{
3030
VmLifecycleState, WriteStdinRequest,
3131
};
3232
use crate::state::{
33-
ActiveExecutionEvent, BridgeError, ConnectionState, JavascriptSocketFamily,
34-
JavascriptSocketPathContext, ProcessEventEnvelope, SessionState, SharedBridge,
33+
ActiveExecutionEvent, BridgeError, ConnectionState, EventSinkTransport, JavascriptSocketFamily,
34+
JavascriptSocketPathContext, ProcessEventEnvelope, SessionState, SharedBridge, SharedEventSink,
3535
SharedSidecarRequestClient, SidecarRequestTransport, VmState, EXECUTION_DRIVER_NAME,
3636
};
3737
use crate::tools::register_host_callbacks;
@@ -870,6 +870,7 @@ pub struct NativeSidecar<B> {
870870
pub(crate) pending_sidecar_responses_gauge: Arc<QueueGauge>,
871871
pub(crate) outbound_sidecar_requests_gauge: Arc<QueueGauge>,
872872
pub(crate) sidecar_requests: SharedSidecarRequestClient,
873+
pub(crate) event_sink: SharedEventSink,
873874
pub(crate) extensions: BTreeMap<String, Arc<dyn Extension>>,
874875
pub(crate) extension_sessions: BTreeMap<(String, String), ExtensionSessionResources>,
875876
pub(crate) extension_process_output_buffers:
@@ -980,6 +981,7 @@ where
980981
MAX_OUTBOUND_SIDECAR_REQUESTS,
981982
),
982983
sidecar_requests: SharedSidecarRequestClient::default(),
984+
event_sink: SharedEventSink::default(),
983985
extensions: BTreeMap::new(),
984986
extension_sessions: BTreeMap::new(),
985987
extension_process_output_buffers: BTreeMap::new(),
@@ -1162,6 +1164,10 @@ where
11621164
self.sidecar_requests.set_transport(transport);
11631165
}
11641166

1167+
pub fn set_event_transport(&mut self, transport: Arc<dyn EventSinkTransport>) {
1168+
self.event_sink.set_transport(transport);
1169+
}
1170+
11651171
pub fn register_extension(
11661172
&mut self,
11671173
extension: Box<dyn Extension>,
@@ -1488,6 +1494,7 @@ where
14881494
namespace.clone(),
14891495
request.ownership.clone(),
14901496
self.sidecar_requests.clone(),
1497+
self.event_sink.clone(),
14911498
);
14921499
let ctx = ExtensionContext::new(snapshot, self);
14931500
let response = extension.handle_request(ctx, envelope.payload).await?;
@@ -1898,6 +1905,7 @@ where
18981905
extension.namespace().to_owned(),
18991906
ownership.clone(),
19001907
self.sidecar_requests.clone(),
1908+
self.event_sink.clone(),
19011909
);
19021910
if let Err(error) = extension.on_session_disposed(snapshot).await {
19031911
if first_error.is_none() {

‎crates/sidecar/src/state.rs‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,45 @@ impl SharedSidecarRequestClient {
198198
}
199199
}
200200

201+
/// Fire-and-forget live event sink. Lets an extension emit a `session/update`
202+
/// (or any other) event frame to the host *mid-dispatch*, instead of having to
203+
/// return it from the dispatch and wait for the whole request to resolve before
204+
/// the stdio loop flushes it. Mirrors `SidecarRequestTransport`, but events have
205+
/// no response, no request id, and no timeout — they are written to the same
206+
/// outbound stdout channel the batch path uses.
207+
pub trait EventSinkTransport: Send + Sync {
208+
fn emit_event(&self, event: crate::wire::EventFrame) -> Result<(), SidecarError>;
209+
}
210+
211+
#[derive(Clone, Default)]
212+
pub(crate) struct SharedEventSink {
213+
transport: Option<Arc<dyn EventSinkTransport>>,
214+
}
215+
216+
impl SharedEventSink {
217+
pub(crate) fn set_transport(&mut self, transport: Arc<dyn EventSinkTransport>) {
218+
self.transport = Some(transport);
219+
}
220+
221+
/// Emit `event` live if a transport is configured (the stdio path). Returns
222+
/// `Ok(None)` when the event was handed to the live transport, or
223+
/// `Ok(Some(event))` when no transport is configured (e.g. an in-process
224+
/// `NativeSidecar` with no stdout loop) so the caller can fall back to the
225+
/// batch path and still deliver the event when the dispatch resolves.
226+
pub(crate) fn try_emit(
227+
&self,
228+
event: crate::wire::EventFrame,
229+
) -> Result<Option<crate::wire::EventFrame>, SidecarError> {
230+
match self.transport.as_ref() {
231+
Some(transport) => {
232+
transport.emit_event(event)?;
233+
Ok(None)
234+
}
235+
None => Ok(Some(event)),
236+
}
237+
}
238+
}
239+
201240
// ---------------------------------------------------------------------------
202241
// Bridge wrapper
203242
// ---------------------------------------------------------------------------

‎crates/sidecar/src/stdio.rs‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ use crate::wire::{
44
SidecarResponseFrame, WireDispatchResult, WireFrameCodec,
55
};
66
use crate::{
7-
Extension, ExtensionInterruptRequest, NativeSidecar, NativeSidecarConfig, SidecarError,
8-
SidecarRequestTransport,
7+
EventSinkTransport, Extension, ExtensionInterruptRequest, NativeSidecar, NativeSidecarConfig,
8+
SidecarError, SidecarRequestTransport,
99
};
1010
use secure_exec_bridge::queue_tracker::{tracked_sync_channel, TrackedLimit, TrackedSyncSender};
1111
use secure_exec_bridge::{
@@ -155,6 +155,12 @@ async fn run_async(extensions: Vec<Box<dyn Extension>>) -> Result<(), Box<dyn Er
155155
}));
156156
let callback_transport = Arc::new(FrameSidecarRequestTransport::new(write_tx.clone()));
157157
sidecar.set_sidecar_request_transport(callback_transport.clone());
158+
// Live event sink: lets an extension stream `session/update` (and other)
159+
// events to stdout mid-dispatch instead of batching them until the request
160+
// resolves. Shares the same outbound `write_tx` channel as the batch path, so
161+
// ordering and backpressure are identical.
162+
let event_transport = Arc::new(FrameEventTransport::new(write_tx.clone()));
163+
sidecar.set_event_transport(event_transport);
158164
let mut event_pump = time::interval(EVENT_PUMP_INTERVAL);
159165
let writer_codec = codec.clone();
160166
let reader_codec = codec.clone();
@@ -1381,6 +1387,28 @@ impl SessionScope {
13811387
}
13821388
}
13831389

1390+
/// Live event sink backed by the outbound stdout channel. Writes each event as a
1391+
/// `ProtocolFrame::EventFrame` immediately, using the same blocking
1392+
/// backpressure semantics as the batch event path (`send_output_frame`): a full
1393+
/// queue parks the producer until the writer thread drains stdout rather than
1394+
/// tearing down the process.
1395+
struct FrameEventTransport {
1396+
writer: TrackedSyncSender<ProtocolFrame>,
1397+
}
1398+
1399+
impl FrameEventTransport {
1400+
fn new(writer: TrackedSyncSender<ProtocolFrame>) -> Self {
1401+
Self { writer }
1402+
}
1403+
}
1404+
1405+
impl EventSinkTransport for FrameEventTransport {
1406+
fn emit_event(&self, event: crate::wire::EventFrame) -> Result<(), SidecarError> {
1407+
send_output_frame(&self.writer, ProtocolFrame::EventFrame(event))
1408+
.map_err(|error| SidecarError::Bridge(error.to_string()))
1409+
}
1410+
}
1411+
13841412
struct FrameSidecarRequestTransport {
13851413
writer: TrackedSyncSender<ProtocolFrame>,
13861414
pending: Arc<Mutex<BTreeMap<RequestId, mpsc::SyncSender<SidecarResponseFrame>>>>,

‎package.json‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,11 @@
1919
"turbo": "^2.5.6",
2020
"typescript": "^5.9.2",
2121
"vitest": "^2.1.8"
22+
},
23+
"pnpm": {
24+
"overrides": {
25+
"@mariozechner/pi-tui": "0.60.0",
26+
"@mariozechner/pi-agent-core": "0.60.0"
27+
}
2228
}
2329
}

0 commit comments

Comments
 (0)