1	//! The broker kernel (B1 + B2) — the stable half of the broker/brain split.
2	//!
3	//! Per ADR-0004 §B the broker holds **only** the resources a live consumer
4	//! would lose continuity on if the logic restarted: the PTY master+writer and
5	//! the spawned child (both via [`spt_term::PtySession`]), and the accepted
6	//! client / listening sockets. It runs **no logic** — no routing, registry,
7	//! manifest parse, or lifecycle. It is a relay: spawn a child under a PTY,
8	//! forward its output to the brain, inject the brain's input, report exit.
9	//!
10	//! ## Gapless handoff (B2 — the load-bearing invariant)
11	//!
12	//! A brain may be killed and restarted at any moment (a routine self-update, a
13	//! crash). The broker — and every hosted child — must survive that untouched,
14	//! and the new brain must re-attach to the output stream **gaplessly and
15	//! exactly-once** (Spike #1 made real). Two mechanisms:
16	//!
17	//! - **Sequenced, buffered output.** Each session's output is appended to an
18	//!   [`OutputLog`]: every chunk gets a monotonic per-session `seq` and is held
19	//!   in a bounded ring. The current subscriber (a brain connection) receives
20	//!   chunks live; all subscriber writes happen *under the log lock*, so frames
21	//!   reach the brain in strict `seq` order with no interleave.
22	//! - **Resume from a cursor.** A reconnecting brain sends `subscribe` with its
23	//!   last-seen `seq + 1`; the broker replays the ring from there, then streams
24	//!   live. The brain dedups by `seq`. Output produced *while no brain was
25	//!   attached* sits in the ring and is replayed on the next subscribe — so a
26	//!   brain restart loses nothing.
27	//!
28	//! Sessions are therefore **broker-owned and outlive any one connection**: a
29	//! brain disconnect only detaches the subscriber; the child, its PTY, and the
30	//! output log persist. A session leaves the table only when its child exits
31	//! (reaped by the exit waiter) or is explicitly killed.
32	// [impl->REQ-DAEMON-2]
33
34	use std::collections::{HashMap, VecDeque};
35	use std::io;
36	use std::sync::atomic::{AtomicU64, Ordering};
37	use std::sync::{Arc, Mutex};
38	use std::thread::{self, JoinHandle};
39
40	use interprocess::local_socket::{prelude::*, SendHalf, Stream};
41	use spt_term::{Drain, PtySession, SessionSurface, SurfaceSize};
42
43	use spt_net::net::endpoint::EndpointAddr;
44
45	use crate::codec::{read_frame, write_frame};
46	use crate::effect::{EffectJournal, EffectKind, Outcome};
47	use crate::frame::{Envelope, Role};
48	use crate::msg::{
49	    applied_envelope, decode_bytes, net_dialed_envelope, output_envelope, ErrorEvent, ExitEvent,
50	    InputReq, KillReq, NetDialReq, NetSent, NetStatusReply, NetStreamOpenReq, NetStreamOpened,
51	    NetStreamSendReq, NetStreamSubscribeReq, NetStreamsReply, ResizeReq, SpawnReq, Spawned,
52	    SubscribeReq, KIND_ERROR, KIND_EXIT, KIND_INPUT, KIND_KILL, KIND_NET_DIAL, KIND_NET_SENT,
53	    KIND_NET_STATUS, KIND_NET_STATUS_REPLY, KIND_NET_STREAMS, KIND_NET_STREAMS_REPLY,
54	    KIND_NET_STREAM_OPEN, KIND_NET_STREAM_OPENED, KIND_NET_STREAM_SEND, KIND_NET_STREAM_SUBSCRIBE,
55	    KIND_RESIZE, KIND_SPAWN, KIND_SPAWNED, KIND_SUBSCRIBE,
56	};
57	use crate::nethost::{NetHost, NET_EFFECT_SESSION};
58	use crate::transport::{recv_hello, DaemonTransport, LocalSocketTransport};
59
60	/// The shared, serialized send side of one brain connection. Output frames,
61	/// replay frames, command acks, and exit/error events all write through this.
62	/// `pub(crate)`: the net host's stream logs (D4b) live-send through the same
63	/// handle under the same lock discipline.
64	pub(crate) type SharedSend = Arc<Mutex<SendHalf>>;
65
66	/// Default bounded output-log depth (chunks). Large enough that a brain restart
67	/// window never evicts unreplayed output in practice; the bound is what keeps a
68	/// disconnected session from buffering without limit.
69	const DEFAULT_LOG_CHUNKS: usize = 4096;
70
71	/// A bounded, sequenced output log for one session — the gapless-resume buffer.
72	///
73	/// The session's single drain thread is the only producer ([`OutputLog::append`]);
74	/// a brain connection attaches as the subscriber ([`OutputLog::attach`]). Every
75	/// send to the subscriber happens while the enclosing `Mutex<OutputLog>` is
76	/// held, so the live stream and a replay can never interleave out of `seq`
77	/// order.
78	struct OutputLog {
79	    session_id: u64,
80	    next_seq: u64,
81	    ring: VecDeque<(u64, Vec<u8>)>,
82	    cap_chunks: usize,
83	    subscriber: Option<SharedSend>,
84	}
85
86	impl OutputLog {
87	    fn new(session_id: u64, cap_chunks: usize) -> Self {
88	        OutputLog {
89	            session_id,
90	            next_seq: 0,
91	            ring: VecDeque::new(),
92	            cap_chunks,
93	            subscriber: None,
94	        }
95	    }
96
97	    /// Append a freshly-produced output chunk: assign the next `seq`, retain it
98	    /// in the bounded ring, and live-send it to the current subscriber (if any).
99	    /// Called only from the session's drain thread.
100	    fn append(&mut self, bytes: &[u8]) {
101	        let seq = self.next_seq;
102	        self.next_seq += 1;
103	        self.ring.push_back((seq, bytes.to_vec()));
104	        while self.ring.len() > self.cap_chunks {
105	            self.ring.pop_front();
106	        }
107	        if let Some(sub) = &self.subscriber {
108	            let frame = output_envelope(self.session_id, seq, bytes);
109	            if let Ok(mut s) = sub.lock() {
110	                let _ = write_frame(&mut *s, &frame);
111	            }
112	        }
113	    }
114
115	    /// Attach `sub` as the subscriber and replay every retained chunk with
116	    /// `seq >= from_seq` before live streaming resumes. Replaces any prior
117	    /// subscriber (a dead brain).
118	    fn attach(&mut self, sub: SharedSend, from_seq: u64) {
119	        self.subscriber = Some(Arc::clone(&sub));
120	        for (seq, bytes) in self.ring.iter().filter(|(s, _)| *s >= from_seq) {
121	            let frame = output_envelope(self.session_id, *seq, bytes);
122	            if let Ok(mut s) = sub.lock() {
123	                let _ = write_frame(&mut *s, &frame);
124	            }
125	        }
126	    }
127
128	    /// Detach `sub` *iff* it is the current subscriber. A no-op if a newer brain
129	    /// has already taken over (so a late disconnect can't unsubscribe the live
130	    /// brain).
131	    fn detach_if(&mut self, sub: &SharedSend) {
132	        if let Some(cur) = &self.subscriber {
133	            if Arc::ptr_eq(cur, sub) {
134	                self.subscriber = None;
135	            }
136	        }
137	    }
138	}
139
140	/// A child the broker hosts under a PTY — the §B-owned resources for one
141	/// session, plus its gapless-resume output log.
142	struct HostedSession {
143	    /// The PTY + child (PTY master/writer + spawned child process).
144	    session: Arc<PtySession>,
145	    /// The output drain pump (kept so teardown can stop it).
146	    #[allow(dead_code)] // held for ownership/teardown; not read directly
147	    drain: Drain,
148	    /// The sequenced, buffered output + current subscriber.
149	    log: Arc<Mutex<OutputLog>>,
150	}
151
152	/// The broker process kernel. Owns the listening socket, the table of hosted
153	/// sessions, and the broker-owned idempotency anchor (B5); holds no logic state
154	/// (ADR-0004 §B). The [`EffectJournal`] is a §B resource — the durable
155	/// recovery anchor a brain crash must not lose, so it lives in the broker, not
156	/// the brain (Spike #6 constraint 1).
157	pub struct Broker {
158	    listener: LocalSocketTransport,
159	    sessions: Arc<Mutex<HashMap<u64, HostedSession>>>,
160	    next_id: AtomicU64,
161	    log_cap_chunks: usize,
162	    /// The exactly-once anchor for idempotent (`op_id`-carrying) effects.
163	    journal: Arc<EffectJournal>,
164	    /// The broker-owned net endpoint + live QUIC connections (D4a, ADR-0004 §B).
165	    /// `None` when bound without a network host (net-disabled / most tests).
166	    net: Option<NetHost>,
167	}
168
169	impl Broker {
170	    /// Bind the broker's listening endpoint at the namespaced `name`, with the
171	    /// effect journal at the default path (`<spt_home>/daemon-effects.log`).
172	    pub fn bind(name: &str) -> io::Result<Arc<Self>> {
173	        Self::bind_in(name, default_journal_path())
174	    }
175
176	    /// Bind with an explicit effect-journal path — the testable seam (tests point
177	    /// the broker-owned anchor at a temp file rather than the shared home).
178	    pub fn bind_in(name: &str, journal_path: std::path::PathBuf) -> io::Result<Arc<Self>> {
179	        Self::bind_in_with_net(name, journal_path, None)
180	    }
181
182	    /// Bind with an already-started broker-owned network host (D4a). Taking the
183	    /// host (not its config) keeps a net bind failure separable from a listener
184	    /// bind failure — the daemon degrades to a net-less broker rather than
185	    /// losing PTY hosting (see `Daemon::run`).
186	    // [impl->REQ-DAEMON-2]
187	    pub fn bind_in_with_net(
188	        name: &str,
189	        journal_path: std::path::PathBuf,
190	        net: Option<NetHost>,
191	    ) -> io::Result<Arc<Self>> {
192	        Ok(Arc::new(Broker {
193	            listener: LocalSocketTransport::bind(name)?,
194	            sessions: Arc::new(Mutex::new(HashMap::new())),
195	            next_id: AtomicU64::new(1),
196	            log_cap_chunks: DEFAULT_LOG_CHUNKS,
197	            journal: Arc::new(EffectJournal::open(journal_path)?),
198	            net,
199	        }))
200	    }
201
202	    /// The broker-owned exactly-once anchor (for introspection / tests).
203	    pub fn journal(&self) -> &Arc<EffectJournal> {
204	        &self.journal
205	    }
206
207	    /// The broker-owned network host, if this broker was bound with one.
208	    pub fn net(&self) -> Option<&NetHost> {
209	        self.net.as_ref()
210	    }
211
212	    /// Accept connections forever, handling each on its own thread. The broker
213	    /// is the long-lived process; this is its main loop.
214	    pub fn serve(self: &Arc<Self>) -> io::Result<()> {
215	        loop {
216	            let conn = self.listener.accept()?;
217	            let me = Arc::clone(self);
218	            thread::spawn(move || {
219	                let _ = me.handle_conn(conn);
220	            });
221	        }
222	    }
223
224	    /// Accept exactly one connection and handle it on a spawned thread, returning
225	    /// that thread's handle. The single-brain entry point the tests exercise.
226	    pub fn serve_once(self: &Arc<Self>) -> io::Result<JoinHandle<()>> {
227	        let conn = self.listener.accept()?;
228	        let me = Arc::clone(self);
229	        Ok(thread::spawn(move || {
230	            let _ = me.handle_conn(conn);
231	        }))
232	    }
233
234	    /// Number of currently hosted sessions (for tests / introspection).
235	    pub fn session_count(&self) -> usize {
236	        self.sessions.lock().unwrap().len()
237	    }
238
239	    /// The child pid of a hosted session, if present (tests assert it is stable
240	    /// across brain restarts).
241	    pub fn session_pid(&self, id: u64) -> Option<u32> {
242	        self.sessions.lock().unwrap().get(&id).and_then(|h| h.session.process_id())
243	    }
244
245	    /// Drive one brain connection: handshake, then dispatch its command frames
246	    /// until it disconnects. On disconnect, detach this connection from any
247	    /// sessions it subscribed to — the sessions themselves persist (B2: a brain
248	    /// restart must not disturb the hosted children).
249	    fn handle_conn(self: &Arc<Self>, conn: Stream) -> io::Result<()> {
250	        let (mut recv, send) = conn.split();
251	        let send: SharedSend = Arc::new(Mutex::new(send));
252
253	        // Version handshake: this connection must be a brain (2.3).
254	        recv_hello(&mut recv, Role::Brain)?;
255
256	        let mut my_subs: Vec<u64> = Vec::new();
257	        let mut my_stream_subs: Vec<u64> = Vec::new();
258
259	        loop {
260	            let env = match read_frame(&mut recv) {
261	                Ok(e) => e,
262	                // Any read failure (EOF included) ends the connection.
263	                Err(_) => break,
264	            };
265	            match env.kind.as_str() {
266	                KIND_SPAWN => match self.dispatch_spawn(env, &send) {
267	                    Ok(id) => my_subs.push(id),
268	                    Err(msg) => send_error(&send, &msg),
269	                },
270	                KIND_SUBSCRIBE => match self.dispatch_subscribe(env, &send) {
271	                    Ok(id) => my_subs.push(id),
272	                    Err(msg) => send_error(&send, &msg),
273	                },
274	                KIND_INPUT => {
275	                    if let Err(msg) = self.dispatch_input(env, &send) {
276	                        send_error(&send, &msg);
277	                    }
278	                }
279	                KIND_RESIZE => {
280	                    if let Err(msg) = self.dispatch_resize(env) {
281	                        send_error(&send, &msg);
282	                    }
283	                }
284	                KIND_KILL => {
285	                    if let Err(msg) = self.dispatch_kill(env) {
286	                        send_error(&send, &msg);
287	                    }
288	                }
289	                KIND_NET_STATUS => self.dispatch_net_status(&send),
290	                KIND_NET_DIAL => {
291	                    if let Err(msg) = self.dispatch_net_dial(env, &send) {
292	                        send_error(&send, &msg);
293	                    }
294	                }
295	                KIND_NET_STREAM_OPEN => {
296	                    if let Err(msg) = self.dispatch_net_stream_open(env, &send) {
297	                        send_error(&send, &msg);
298	                    }
299	                }
300	                KIND_NET_STREAM_SEND => {
301	                    if let Err(msg) = self.dispatch_net_stream_send(env, &send) {
302	                        send_error(&send, &msg);
303	                    }
304	                }
305	                KIND_NET_STREAMS => {
306	                    if let Err(msg) = self.dispatch_net_streams(&send) {
307	                        send_error(&send, &msg);
308	                    }
309	                }
310	                KIND_NET_STREAM_SUBSCRIBE => {
311	                    match self.dispatch_net_stream_subscribe(env, &send) {
312	                        Ok(id) => my_stream_subs.push(id),
313	                        Err(msg) => send_error(&send, &msg),
314	                    }
315	                }
316	                // An unknown kind is tolerated (forward-compat): report, don't die.
317	                other => send_error(&send, &format!("unknown command kind {other:?}")),
318	            }
319	        }
320
321	        // Brain gone: detach as subscriber but leave the sessions running.
322	        let sessions = self.sessions.lock().unwrap();
323	        for id in &my_subs {
324	            if let Some(h) = sessions.get(id) {
325	                h.log.lock().unwrap().detach_if(&send);
326	            }
327	        }
328	        drop(sessions);
329	        // Same for net streams: the streams + their rings persist (D4b).
330	        if let Some(host) = &self.net {
331	            for id in &my_stream_subs {
332	                host.detach_stream_sub(*id, &send);
333	            }
334	        }
335	        Ok(())
336	    }
337
338	    /// Spawn a child under a fresh PTY, register it, auto-subscribe the spawning
339	    /// connection, start its output drain and exit waiter, and reply `spawned`.
340	    fn dispatch_spawn(&self, env: Envelope, send: &SharedSend) -> Result<u64, String> {
341	        let req: SpawnReq = serde_json::from_value(env.payload)
342	            .map_err(|e| format!("bad spawn payload: {e}"))?;
343	        let session = PtySession::spawn_program(
344	            &req.program,
345	            &req.args,
346	            SurfaceSize::new(req.rows, req.cols),
347	        )
348	        .map_err(|e| format!("spawn failed: {e}"))?;
349	        let session = Arc::new(session);
350	        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
351	        let pid = session.process_id();
352
353	        // The output log, with the spawning connection pre-attached so no chunk
354	        // is missed before an explicit subscribe.
355	        let log = Arc::new(Mutex::new(OutputLog::new(id, self.log_cap_chunks)));
356	        log.lock().unwrap().attach(Arc::clone(send), 0);
357
358	        // Output drain → appends to the log (which live-sends to the subscriber).
359	        let log_drain = Arc::clone(&log);
360	        let drain = session
361	            .drain(move |chunk| {
362	                log_drain.lock().unwrap().append(chunk);
363	            })
364	            .map_err(|e| format!("drain failed: {e}"))?;
365
366	        // Exit waiter → one `exit` frame when the child is reaped, then the dead
367	        // session is removed from the table. Runs on its own thread; `wait`
368	        // holds the child lock but `kill` uses a split killer.
369	        let sessions_exit = Arc::clone(&self.sessions);
370	        let log_exit = Arc::clone(&log);
371	        let waiter = Arc::clone(&session);
372	        thread::spawn(move || {
373	            if let Ok(status) = waiter.wait() {
374	                let ev = ExitEvent { session_id: id, code: Some(status.exit_code() as i32) };
375	                let frame = Envelope::new(
376	                    KIND_EXIT,
377	                    serde_json::to_value(ev).expect("ExitEvent serializes"),
378	                );
379	                // Send to whoever is currently subscribed (under the log lock).
380	                if let Some(sub) = log_exit.lock().unwrap().subscriber.clone() {
381	                    if let Ok(mut s) = sub.lock() {
382	                        let _ = write_frame(&mut *s, &frame);
383	                    }
384	                }
385	            }
386	            // Reap the dead session from the table.
387	            sessions_exit.lock().unwrap().remove(&id);
388	        });
389
390	        self.sessions.lock().unwrap().insert(id, HostedSession { session, drain, log });
391
392	        let resp = Spawned { session_id: id, pid };
393	        let frame =
394	            Envelope::new(KIND_SPAWNED, serde_json::to_value(resp).expect("Spawned serializes"));
395	        send_frame(send, &frame);
396	        Ok(id)
397	    }
398
399	    /// Re-attach a (handed-off) brain to a session's output, replaying from
400	    /// `from_seq` for gapless, exactly-once resume.
401	    fn dispatch_subscribe(&self, env: Envelope, send: &SharedSend) -> Result<u64, String> {
402	        let req: SubscribeReq = serde_json::from_value(env.payload)
403	            .map_err(|e| format!("bad subscribe payload: {e}"))?;
404	        // Clone the log Arc out from under the sessions lock, then replay under
405	        // the log lock — never hold both locks at once.
406	        let log = {
407	            let sessions = self.sessions.lock().unwrap();
408	            let h = sessions
409	                .get(&req.session_id)
410	                .ok_or_else(|| format!("no such session {}", req.session_id))?;
411	            Arc::clone(&h.log)
412	        };
413	        log.lock().unwrap().attach(Arc::clone(send), req.from_seq);
414	        Ok(req.session_id)
415	    }
416
417	    /// Inject input bytes into a session's PTY.
418	    ///
419	    /// With an `op_id` the write is routed through the broker-owned
420	    /// [`EffectJournal`] for exactly-once delivery: a replay of the same
421	    /// `(session_id, op_id)` after a brain crash is deduped (the PTY is not
422	    /// written twice), and either outcome — fresh apply or dedup — is acked back
423	    /// with an [`crate::msg::KIND_APPLIED`] frame so the brain can retire the
424	    /// effect (B5 / KNOWN-HAZARDS 7.2). Without an `op_id` it is the legacy
425	    /// direct write (no dedup, no ack).
426	    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
427	    fn dispatch_input(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
428	        let req: InputReq = serde_json::from_value(env.payload)
429	            .map_err(|e| format!("bad input payload: {e}"))?;
430	        let bytes = decode_bytes(&req.data_b64).map_err(|e| format!("bad input base64: {e}"))?;
431	        // Clone the session Arc out from under the sessions lock so the PTY write
432	        // + fsync happen without holding the table lock (mirrors dispatch_subscribe).
433	        let session = {
434	            let sessions = self.sessions.lock().unwrap();
435	            let h = sessions
436	                .get(&req.session_id)
437	                .ok_or_else(|| format!("no such session {}", req.session_id))?;
438	            Arc::clone(&h.session)
439	        };
440	        match req.op_id {
441	            Some(op) => {
442	                let key = (req.session_id, op);
443	                let outcome = self
444	                    .journal
445	                    .apply_once(key, EffectKind::PtyWrite, || {
446	                        session.write_input(&bytes).map_err(|e| io::Error::other(e.to_string()))
447	                    })
448	                    .map_err(|e| format!("write_input failed: {e}"))?;
449	                // Ack exactly-once: both Applied and Deduped mean "durably landed".
450	                send_frame(send, &applied_envelope(req.session_id, op, outcome == Outcome::Applied));
451	                Ok(())
452	            }
453	            None => session.write_input(&bytes).map_err(|e| format!("write_input failed: {e}")),
454	        }
455	    }
456
457	    /// Resize a session's PTY.
458	    fn dispatch_resize(&self, env: Envelope) -> Result<(), String> {
459	        let req: ResizeReq = serde_json::from_value(env.payload)
460	            .map_err(|e| format!("bad resize payload: {e}"))?;
461	        let sessions = self.sessions.lock().unwrap();
462	        let h = sessions
463	            .get(&req.session_id)
464	            .ok_or_else(|| format!("no such session {}", req.session_id))?;
465	        h.session
466	            .resize(SurfaceSize::new(req.rows, req.cols))
467	            .map_err(|e| format!("resize failed: {e}"))
468	    }
469
470	    /// Report the broker-owned net endpoint's status (D4a). Answered even when
471	    /// the broker has no network host (`enabled: false`) so a brain can probe
472	    /// capability without treating absence as an error.
473	    // [impl->REQ-DAEMON-2]
474	    fn dispatch_net_status(&self, send: &SharedSend) {
475	        let reply = match &self.net {
476	            Some(host) => NetStatusReply {
477	                enabled: true,
478	                node_id_hex: Some(host.node_id_hex()),
479	                addr: host.addr_json(),
480	                conn_count: host.conn_count(),
481	            },
482	            None => NetStatusReply {
483	                enabled: false,
484	                node_id_hex: None,
485	                addr: serde_json::Value::Null,
486	                conn_count: 0,
487	            },
488	        };
489	        let frame = Envelope::new(
490	            KIND_NET_STATUS_REPLY,
491	            serde_json::to_value(reply).expect("NetStatusReply serializes"),
492	        );
493	        send_frame(send, &frame);
494	    }
495
496	    /// Dial a peer over the broker-owned endpoint (D4a).
497	    ///
498	    /// With an `op_id` the dial is routed through the broker-owned
499	    /// [`EffectJournal`] under the reserved [`NET_EFFECT_SESSION`] namespace,
500	    /// exactly like an idempotent PTY write: a replay of the same id after a
501	    /// brain crash is deduped (no second connection) and answered with the
502	    /// connection the first apply opened. The `conn_id` is recorded *inside*
503	    /// the journaled closure, so a concurrent deduped replay always finds it.
504	    /// A deduped id whose connection this process no longer holds (a broker
505	    /// restart — the journal survives, connections don't) is a typed error
506	    /// telling the brain to dial under a fresh id.
507	    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
508	    fn dispatch_net_dial(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
509	        let req: NetDialReq = serde_json::from_value(env.payload)
510	            .map_err(|e| format!("bad net-dial payload: {e}"))?;
511	        let host = self
512	            .net
513	            .as_ref()
514	            .ok_or("net disabled: this broker was bound without a network host")?;
515	        let addr: EndpointAddr = serde_json::from_value(req.addr)
516	            .map_err(|e| format!("bad net-dial addr: {e}"))?;
517	        match req.op_id {
518	            None => {
519	                // Direct (non-journaled) dial — no dedup, like a no-op_id input.
520	                let (conn_id, remote) = host.dial(addr).map_err(|e| e.to_string())?;
521	                send_frame(send, &net_dialed_envelope(conn_id, remote, None, true));
522	                Ok(())
523	            }
524	            Some(op) => {
525	                let outcome = self
526	                    .journal
527	                    .apply_once((NET_EFFECT_SESSION, op), EffectKind::NetDial, || {
528	                        let (conn_id, _) = host.dial(addr)?;
529	                        host.record_dial_op(op, conn_id);
530	                        Ok(())
531	                    })
532	                    .map_err(|e| format!("net-dial failed: {e}"))?;
533	                let conn_id = host.dial_op_conn(op).ok_or(
534	                    "net-dial op already applied but its connection is no longer held \
535	                     (broker restarted); retry with a fresh op_id",
536	                )?;
537	                let remote = host
538	                    .conn_remote_hex(conn_id)
539	                    .ok_or("dialed connection missing from the conn table")?;
540	                let applied_now = outcome == Outcome::Applied;
541	                send_frame(send, &net_dialed_envelope(conn_id, remote, Some(op), applied_now));
542	                Ok(())
543	            }
544	        }
545	    }
546
547	    /// Open a bidi stream on a held connection (D4b). Journaled exactly-once
548	    /// under the same discipline as [`Self::dispatch_net_dial`]: the minted
549	    /// `stream_id` is recorded inside the `apply_once` closure, and a deduped
550	    /// replay is answered with the stream the first apply opened.
551	    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
552	    fn dispatch_net_stream_open(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
553	        let req: NetStreamOpenReq = serde_json::from_value(env.payload)
554	            .map_err(|e| format!("bad net-stream-open payload: {e}"))?;
555	        let host = self.net.as_ref().ok_or("net disabled")?;
556	        let build = |stream_id: u64, conn_id: u64, op_id: Option<u64>, applied_now: bool| {
557	            Envelope::new(
558	                KIND_NET_STREAM_OPENED,
559	                serde_json::to_value(NetStreamOpened { stream_id, conn_id, op_id, applied_now })
560	                    .expect("NetStreamOpened serializes"),
561	            )
562	        };
563	        match req.op_id {
564	            None => {
565	                let stream_id = host.open_stream(req.conn_id).map_err(|e| e.to_string())?;
566	                send_frame(send, &build(stream_id, req.conn_id, None, true));
567	                Ok(())
568	            }
569	            Some(op) => {
570	                let outcome = self
571	                    .journal
572	                    .apply_once((NET_EFFECT_SESSION, op), EffectKind::NetSend, || {
573	                        let stream_id = host.open_stream(req.conn_id)?;
574	                        host.record_stream_op(op, stream_id);
575	                        Ok(())
576	                    })
577	                    .map_err(|e| format!("net-stream-open failed: {e}"))?;
578	                let stream_id = host.stream_op_id(op).ok_or(
579	                    "stream-open op already applied but its stream is no longer held \
580	                     (broker restarted); retry with a fresh op_id",
581	                )?;
582	                let applied_now = outcome == Outcome::Applied;
583	                send_frame(send, &build(stream_id, req.conn_id, Some(op), applied_now));
584	                Ok(())
585	            }
586	        }
587	    }
588
589	    /// Write bytes to a held stream's send side (D4b). With an `op_id` the
590	    /// write is exactly-once through the broker-owned journal (a replayed
591	    /// chunk after a brain crash is not written twice — Spike #3 invariant D
592	    /// on the send path) and acked with `net-sent`; without one it is a direct
593	    /// write (no dedup, no ack), mirroring the PTY `input` contract.
594	    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
595	    fn dispatch_net_stream_send(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
596	        let req: NetStreamSendReq = serde_json::from_value(env.payload)
597	            .map_err(|e| format!("bad net-stream-send payload: {e}"))?;
598	        let bytes =
599	            decode_bytes(&req.data_b64).map_err(|e| format!("bad net-stream-send base64: {e}"))?;
600	        let host = self.net.as_ref().ok_or("net disabled")?;
601	        match req.op_id {
602	            None => host
603	                .send_stream(req.stream_id, &bytes, req.finish)
604	                .map_err(|e| e.to_string()),
605	            Some(op) => {
606	                let outcome = self
607	                    .journal
608	                    .apply_once((NET_EFFECT_SESSION, op), EffectKind::NetSend, || {
609	                        host.send_stream(req.stream_id, &bytes, req.finish)
610	                    })
611	                    .map_err(|e| format!("net-stream-send failed: {e}"))?;
612	                let frame = Envelope::new(
613	                    KIND_NET_SENT,
614	                    serde_json::to_value(NetSent {
615	                        stream_id: req.stream_id,
616	                        op_id: op,
617	                        applied_now: outcome == Outcome::Applied,
618	                    })
619	                    .expect("NetSent serializes"),
620	                );
621	                send_frame(send, &frame);
622	                Ok(())
623	            }
624	        }
625	    }
626
627	    /// List every stream the broker holds (D4b) — the query a re-attaching
628	    /// brain uses to find streams that arrived while it was down.
629	    fn dispatch_net_streams(&self, send: &SharedSend) -> Result<(), String> {
630	        let host = self.net.as_ref().ok_or("net disabled")?;
631	        let frame = Envelope::new(
632	            KIND_NET_STREAMS_REPLY,
633	            serde_json::to_value(NetStreamsReply { streams: host.stream_infos() })
634	                .expect("NetStreamsReply serializes"),
635	        );
636	        send_frame(send, &frame);
637	        Ok(())
638	    }
639
640	    /// Re-attach a brain to a stream's buffered reads from a cursor (D4b) —
641	    /// the gapless-resume `subscribe` discipline on a QUIC stream.
642	    // [impl->REQ-DAEMON-2]
643	    fn dispatch_net_stream_subscribe(
644	        &self,
645	        env: Envelope,
646	        send: &SharedSend,
647	    ) -> Result<u64, String> {
648	        let req: NetStreamSubscribeReq = serde_json::from_value(env.payload)
649	            .map_err(|e| format!("bad net-stream-subscribe payload: {e}"))?;
650	        let host = self.net.as_ref().ok_or("net disabled")?;
651	        host.subscribe_stream(req.stream_id, Arc::clone(send), req.from_seq)
652	            .map_err(|e| e.to_string())?;
653	        Ok(req.stream_id)
654	    }
655
656	    /// Terminate a session's child. The exit waiter emits the `exit` frame and
657	    /// reaps the session once the child is dead.
658	    fn dispatch_kill(&self, env: Envelope) -> Result<(), String> {
659	        let req: KillReq = serde_json::from_value(env.payload)
660	            .map_err(|e| format!("bad kill payload: {e}"))?;
661	        let sessions = self.sessions.lock().unwrap();
662	        let h = sessions
663	            .get(&req.session_id)
664	            .ok_or_else(|| format!("no such session {}", req.session_id))?;
665	        h.session.kill().map_err(|e| format!("kill failed: {e}"))
666	    }
667	}
668
669	/// The default broker-owned effect-journal path: `<spt_home>/daemon-effects.log`.
670	pub(crate) fn default_journal_path() -> std::path::PathBuf {
671	    spt_store::perch::spt_home().join("daemon-effects.log")
672	}
673
674	/// Write a frame to the brain, ignoring a broken-pipe failure (brain gone).
675	fn send_frame(send: &SharedSend, frame: &Envelope) {
676	    if let Ok(mut s) = send.lock() {
677	        let _ = write_frame(&mut *s, frame);
678	    }
679	}
680
681	/// Send an `error` event to the brain (best-effort).
682	fn send_error(send: &SharedSend, message: &str) {
683	    let frame = Envelope::new(
684	        KIND_ERROR,
685	        serde_json::to_value(ErrorEvent { message: message.to_string() })
686	            .expect("ErrorEvent serializes"),
687	    );
688	    send_frame(send, &frame);
689	}
690

//! The broker kernel (B1 + B2) — the stable half of the broker/brain split.
//!
//! Per ADR-0004 §B the broker holds **only** the resources a live consumer
//! would lose continuity on if the logic restarted: the PTY master+writer and
//! the spawned child (both via [`spt_term::PtySession`]), and the accepted
//! client / listening sockets. It runs **no logic** — no routing, registry,
//! manifest parse, or lifecycle. It is a relay: spawn a child under a PTY,
//! forward its output to the brain, inject the brain's input, report exit.
//!
//! ## Gapless handoff (B2 — the load-bearing invariant)
//!
//! A brain may be killed and restarted at any moment (a routine self-update, a
//! crash). The broker — and every hosted child — must survive that untouched,
//! and the new brain must re-attach to the output stream **gaplessly and
//! exactly-once** (Spike #1 made real). Two mechanisms:
//!
//! - **Sequenced, buffered output.** Each session's output is appended to an
//!   [`OutputLog`]: every chunk gets a monotonic per-session `seq` and is held
//!   in a bounded ring. The current subscriber (a brain connection) receives
//!   chunks live; all subscriber writes happen *under the log lock*, so frames
//!   reach the brain in strict `seq` order with no interleave.
//! - **Resume from a cursor.** A reconnecting brain sends `subscribe` with its
//!   last-seen `seq + 1`; the broker replays the ring from there, then streams
//!   live. The brain dedups by `seq`. Output produced *while no brain was
//!   attached* sits in the ring and is replayed on the next subscribe — so a
//!   brain restart loses nothing.
//!
//! Sessions are therefore **broker-owned and outlive any one connection**: a
//! brain disconnect only detaches the subscriber; the child, its PTY, and the
//! output log persist. A session leaves the table only when its child exits
//! (reaped by the exit waiter) or is explicitly killed.
// [impl->REQ-DAEMON-2]

use std::collections::{HashMap, VecDeque};
use std::io;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};

use interprocess::local_socket::{prelude::*, SendHalf, Stream};
use spt_term::{Drain, PtySession, SessionSurface, SurfaceSize};

use spt_net::net::endpoint::EndpointAddr;

use crate::codec::{read_frame, write_frame};
use crate::effect::{EffectJournal, EffectKind, Outcome};
use crate::frame::{Envelope, Role};
use crate::msg::{
    applied_envelope, decode_bytes, net_dialed_envelope, output_envelope, ErrorEvent, ExitEvent,
    InputReq, KillReq, NetDialReq, NetSent, NetStatusReply, NetStreamOpenReq, NetStreamOpened,
    NetStreamSendReq, NetStreamSubscribeReq, NetStreamsReply, ResizeReq, SpawnReq, Spawned,
    SubscribeReq, KIND_ERROR, KIND_EXIT, KIND_INPUT, KIND_KILL, KIND_NET_DIAL, KIND_NET_SENT,
    KIND_NET_STATUS, KIND_NET_STATUS_REPLY, KIND_NET_STREAMS, KIND_NET_STREAMS_REPLY,
    KIND_NET_STREAM_OPEN, KIND_NET_STREAM_OPENED, KIND_NET_STREAM_SEND, KIND_NET_STREAM_SUBSCRIBE,
    KIND_RESIZE, KIND_SPAWN, KIND_SPAWNED, KIND_SUBSCRIBE,
};
use crate::nethost::{NetHost, NET_EFFECT_SESSION};
use crate::transport::{recv_hello, DaemonTransport, LocalSocketTransport};

/// The shared, serialized send side of one brain connection. Output frames,
/// replay frames, command acks, and exit/error events all write through this.
/// `pub(crate)`: the net host's stream logs (D4b) live-send through the same
/// handle under the same lock discipline.
pub(crate) type SharedSend = Arc<Mutex<SendHalf>>;

/// Default bounded output-log depth (chunks). Large enough that a brain restart
/// window never evicts unreplayed output in practice; the bound is what keeps a
/// disconnected session from buffering without limit.
const DEFAULT_LOG_CHUNKS: usize = 4096;

/// A bounded, sequenced output log for one session — the gapless-resume buffer.
///
/// The session's single drain thread is the only producer ([`OutputLog::append`]);
/// a brain connection attaches as the subscriber ([`OutputLog::attach`]). Every
/// send to the subscriber happens while the enclosing `Mutex<OutputLog>` is
/// held, so the live stream and a replay can never interleave out of `seq`
/// order.
struct OutputLog {
    session_id: u64,
    next_seq: u64,
    ring: VecDeque<(u64, Vec<u8>)>,
    cap_chunks: usize,
    subscriber: Option<SharedSend>,
}

impl OutputLog {
    fn new(session_id: u64, cap_chunks: usize) -> Self {
        OutputLog {
            session_id,
            next_seq: 0,
            ring: VecDeque::new(),
            cap_chunks,
            subscriber: None,
        }
    }

    /// Append a freshly-produced output chunk: assign the next `seq`, retain it
    /// in the bounded ring, and live-send it to the current subscriber (if any).
    /// Called only from the session's drain thread.
    fn append(&mut self, bytes: &[u8]) {
        let seq = self.next_seq;
        self.next_seq += 1;
        self.ring.push_back((seq, bytes.to_vec()));
        while self.ring.len() > self.cap_chunks {
            self.ring.pop_front();
        }
        if let Some(sub) = &self.subscriber {
            let frame = output_envelope(self.session_id, seq, bytes);
            if let Ok(mut s) = sub.lock() {
                let _ = write_frame(&mut *s, &frame);
            }
        }
    }

    /// Attach `sub` as the subscriber and replay every retained chunk with
    /// `seq >= from_seq` before live streaming resumes. Replaces any prior
    /// subscriber (a dead brain).
    fn attach(&mut self, sub: SharedSend, from_seq: u64) {
        self.subscriber = Some(Arc::clone(&sub));
        for (seq, bytes) in self.ring.iter().filter(|(s, _)| *s >= from_seq) {
            let frame = output_envelope(self.session_id, *seq, bytes);
            if let Ok(mut s) = sub.lock() {
                let _ = write_frame(&mut *s, &frame);
            }
        }
    }

    /// Detach `sub` *iff* it is the current subscriber. A no-op if a newer brain
    /// has already taken over (so a late disconnect can't unsubscribe the live
    /// brain).
    fn detach_if(&mut self, sub: &SharedSend) {
        if let Some(cur) = &self.subscriber {
            if Arc::ptr_eq(cur, sub) {
                self.subscriber = None;
            }
        }
    }
}

/// A child the broker hosts under a PTY — the §B-owned resources for one
/// session, plus its gapless-resume output log.
struct HostedSession {
    /// The PTY + child (PTY master/writer + spawned child process).
    session: Arc<PtySession>,
    /// The output drain pump (kept so teardown can stop it).
    #[allow(dead_code)] // held for ownership/teardown; not read directly
    drain: Drain,
    /// The sequenced, buffered output + current subscriber.
    log: Arc<Mutex<OutputLog>>,
}

/// The broker process kernel. Owns the listening socket, the table of hosted
/// sessions, and the broker-owned idempotency anchor (B5); holds no logic state
/// (ADR-0004 §B). The [`EffectJournal`] is a §B resource — the durable
/// recovery anchor a brain crash must not lose, so it lives in the broker, not
/// the brain (Spike #6 constraint 1).
pub struct Broker {
    listener: LocalSocketTransport,
    sessions: Arc<Mutex<HashMap<u64, HostedSession>>>,
    next_id: AtomicU64,
    log_cap_chunks: usize,
    /// The exactly-once anchor for idempotent (`op_id`-carrying) effects.
    journal: Arc<EffectJournal>,
    /// The broker-owned net endpoint + live QUIC connections (D4a, ADR-0004 §B).
    /// `None` when bound without a network host (net-disabled / most tests).
    net: Option<NetHost>,
}

impl Broker {
    /// Bind the broker's listening endpoint at the namespaced `name`, with the
    /// effect journal at the default path (`<spt_home>/daemon-effects.log`).
    pub fn bind(name: &str) -> io::Result<Arc<Self>> {
        Self::bind_in(name, default_journal_path())
    }

    /// Bind with an explicit effect-journal path — the testable seam (tests point
    /// the broker-owned anchor at a temp file rather than the shared home).
    pub fn bind_in(name: &str, journal_path: std::path::PathBuf) -> io::Result<Arc<Self>> {
        Self::bind_in_with_net(name, journal_path, None)
    }

    /// Bind with an already-started broker-owned network host (D4a). Taking the
    /// host (not its config) keeps a net bind failure separable from a listener
    /// bind failure — the daemon degrades to a net-less broker rather than
    /// losing PTY hosting (see `Daemon::run`).
    // [impl->REQ-DAEMON-2]
    pub fn bind_in_with_net(
        name: &str,
        journal_path: std::path::PathBuf,
        net: Option<NetHost>,
    ) -> io::Result<Arc<Self>> {
        Ok(Arc::new(Broker {
            listener: LocalSocketTransport::bind(name)?,
            sessions: Arc::new(Mutex::new(HashMap::new())),
            next_id: AtomicU64::new(1),
            log_cap_chunks: DEFAULT_LOG_CHUNKS,
            journal: Arc::new(EffectJournal::open(journal_path)?),
            net,
        }))
    }

    /// The broker-owned exactly-once anchor (for introspection / tests).
    pub fn journal(&self) -> &Arc<EffectJournal> {
        &self.journal
    }

    /// The broker-owned network host, if this broker was bound with one.
    pub fn net(&self) -> Option<&NetHost> {
        self.net.as_ref()
    }

    /// Accept connections forever, handling each on its own thread. The broker
    /// is the long-lived process; this is its main loop.
    pub fn serve(self: &Arc<Self>) -> io::Result<()> {
        loop {
            let conn = self.listener.accept()?;
            let me = Arc::clone(self);
            thread::spawn(move || {
                let _ = me.handle_conn(conn);
            });
        }
    }

    /// Accept exactly one connection and handle it on a spawned thread, returning
    /// that thread's handle. The single-brain entry point the tests exercise.
    pub fn serve_once(self: &Arc<Self>) -> io::Result<JoinHandle<()>> {
        let conn = self.listener.accept()?;
        let me = Arc::clone(self);
        Ok(thread::spawn(move || {
            let _ = me.handle_conn(conn);
        }))
    }

    /// Number of currently hosted sessions (for tests / introspection).
    pub fn session_count(&self) -> usize {
        self.sessions.lock().unwrap().len()
    }

    /// The child pid of a hosted session, if present (tests assert it is stable
    /// across brain restarts).
    pub fn session_pid(&self, id: u64) -> Option<u32> {
        self.sessions.lock().unwrap().get(&id).and_then(|h| h.session.process_id())
    }

    /// Drive one brain connection: handshake, then dispatch its command frames
    /// until it disconnects. On disconnect, detach this connection from any
    /// sessions it subscribed to — the sessions themselves persist (B2: a brain
    /// restart must not disturb the hosted children).
    fn handle_conn(self: &Arc<Self>, conn: Stream) -> io::Result<()> {
        let (mut recv, send) = conn.split();
        let send: SharedSend = Arc::new(Mutex::new(send));

        // Version handshake: this connection must be a brain (2.3).
        recv_hello(&mut recv, Role::Brain)?;

        let mut my_subs: Vec<u64> = Vec::new();
        let mut my_stream_subs: Vec<u64> = Vec::new();

        loop {
            let env = match read_frame(&mut recv) {
                Ok(e) => e,
                // Any read failure (EOF included) ends the connection.
                Err(_) => break,
            };
            match env.kind.as_str() {
                KIND_SPAWN => match self.dispatch_spawn(env, &send) {
                    Ok(id) => my_subs.push(id),
                    Err(msg) => send_error(&send, &msg),
                },
                KIND_SUBSCRIBE => match self.dispatch_subscribe(env, &send) {
                    Ok(id) => my_subs.push(id),
                    Err(msg) => send_error(&send, &msg),
                },
                KIND_INPUT => {
                    if let Err(msg) = self.dispatch_input(env, &send) {
                        send_error(&send, &msg);
                    }
                }
                KIND_RESIZE => {
                    if let Err(msg) = self.dispatch_resize(env) {
                        send_error(&send, &msg);
                    }
                }
                KIND_KILL => {
                    if let Err(msg) = self.dispatch_kill(env) {
                        send_error(&send, &msg);
                    }
                }
                KIND_NET_STATUS => self.dispatch_net_status(&send),
                KIND_NET_DIAL => {
                    if let Err(msg) = self.dispatch_net_dial(env, &send) {
                        send_error(&send, &msg);
                    }
                }
                KIND_NET_STREAM_OPEN => {
                    if let Err(msg) = self.dispatch_net_stream_open(env, &send) {
                        send_error(&send, &msg);
                    }
                }
                KIND_NET_STREAM_SEND => {
                    if let Err(msg) = self.dispatch_net_stream_send(env, &send) {
                        send_error(&send, &msg);
                    }
                }
                KIND_NET_STREAMS => {
                    if let Err(msg) = self.dispatch_net_streams(&send) {
                        send_error(&send, &msg);
                    }
                }
                KIND_NET_STREAM_SUBSCRIBE => {
                    match self.dispatch_net_stream_subscribe(env, &send) {
                        Ok(id) => my_stream_subs.push(id),
                        Err(msg) => send_error(&send, &msg),
                    }
                }
                // An unknown kind is tolerated (forward-compat): report, don't die.
                other => send_error(&send, &format!("unknown command kind {other:?}")),
            }
        }

        // Brain gone: detach as subscriber but leave the sessions running.
        let sessions = self.sessions.lock().unwrap();
        for id in &my_subs {
            if let Some(h) = sessions.get(id) {
                h.log.lock().unwrap().detach_if(&send);
            }
        }
        drop(sessions);
        // Same for net streams: the streams + their rings persist (D4b).
        if let Some(host) = &self.net {
            for id in &my_stream_subs {
                host.detach_stream_sub(*id, &send);
            }
        }
        Ok(())
    }

    /// Spawn a child under a fresh PTY, register it, auto-subscribe the spawning
    /// connection, start its output drain and exit waiter, and reply `spawned`.
    fn dispatch_spawn(&self, env: Envelope, send: &SharedSend) -> Result<u64, String> {
        let req: SpawnReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad spawn payload: {e}"))?;
        let session = PtySession::spawn_program(
            &req.program,
            &req.args,
            SurfaceSize::new(req.rows, req.cols),
        )
        .map_err(|e| format!("spawn failed: {e}"))?;
        let session = Arc::new(session);
        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
        let pid = session.process_id();

        // The output log, with the spawning connection pre-attached so no chunk
        // is missed before an explicit subscribe.
        let log = Arc::new(Mutex::new(OutputLog::new(id, self.log_cap_chunks)));
        log.lock().unwrap().attach(Arc::clone(send), 0);

        // Output drain → appends to the log (which live-sends to the subscriber).
        let log_drain = Arc::clone(&log);
        let drain = session
            .drain(move |chunk| {
                log_drain.lock().unwrap().append(chunk);
            })
            .map_err(|e| format!("drain failed: {e}"))?;

        // Exit waiter → one `exit` frame when the child is reaped, then the dead
        // session is removed from the table. Runs on its own thread; `wait`
        // holds the child lock but `kill` uses a split killer.
        let sessions_exit = Arc::clone(&self.sessions);
        let log_exit = Arc::clone(&log);
        let waiter = Arc::clone(&session);
        thread::spawn(move || {
            if let Ok(status) = waiter.wait() {
                let ev = ExitEvent { session_id: id, code: Some(status.exit_code() as i32) };
                let frame = Envelope::new(
                    KIND_EXIT,
                    serde_json::to_value(ev).expect("ExitEvent serializes"),
                );
                // Send to whoever is currently subscribed (under the log lock).
                if let Some(sub) = log_exit.lock().unwrap().subscriber.clone() {
                    if let Ok(mut s) = sub.lock() {
                        let _ = write_frame(&mut *s, &frame);
                    }
                }
            }
            // Reap the dead session from the table.
            sessions_exit.lock().unwrap().remove(&id);
        });

        self.sessions.lock().unwrap().insert(id, HostedSession { session, drain, log });

        let resp = Spawned { session_id: id, pid };
        let frame =
            Envelope::new(KIND_SPAWNED, serde_json::to_value(resp).expect("Spawned serializes"));
        send_frame(send, &frame);
        Ok(id)
    }

    /// Re-attach a (handed-off) brain to a session's output, replaying from
    /// `from_seq` for gapless, exactly-once resume.
    fn dispatch_subscribe(&self, env: Envelope, send: &SharedSend) -> Result<u64, String> {
        let req: SubscribeReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad subscribe payload: {e}"))?;
        // Clone the log Arc out from under the sessions lock, then replay under
        // the log lock — never hold both locks at once.
        let log = {
            let sessions = self.sessions.lock().unwrap();
            let h = sessions
                .get(&req.session_id)
                .ok_or_else(|| format!("no such session {}", req.session_id))?;
            Arc::clone(&h.log)
        };
        log.lock().unwrap().attach(Arc::clone(send), req.from_seq);
        Ok(req.session_id)
    }

    /// Inject input bytes into a session's PTY.
    ///
    /// With an `op_id` the write is routed through the broker-owned
    /// [`EffectJournal`] for exactly-once delivery: a replay of the same
    /// `(session_id, op_id)` after a brain crash is deduped (the PTY is not
    /// written twice), and either outcome — fresh apply or dedup — is acked back
    /// with an [`crate::msg::KIND_APPLIED`] frame so the brain can retire the
    /// effect (B5 / KNOWN-HAZARDS 7.2). Without an `op_id` it is the legacy
    /// direct write (no dedup, no ack).
    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
    fn dispatch_input(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
        let req: InputReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad input payload: {e}"))?;
        let bytes = decode_bytes(&req.data_b64).map_err(|e| format!("bad input base64: {e}"))?;
        // Clone the session Arc out from under the sessions lock so the PTY write
        // + fsync happen without holding the table lock (mirrors dispatch_subscribe).
        let session = {
            let sessions = self.sessions.lock().unwrap();
            let h = sessions
                .get(&req.session_id)
                .ok_or_else(|| format!("no such session {}", req.session_id))?;
            Arc::clone(&h.session)
        };
        match req.op_id {
            Some(op) => {
                let key = (req.session_id, op);
                let outcome = self
                    .journal
                    .apply_once(key, EffectKind::PtyWrite, || {
                        session.write_input(&bytes).map_err(|e| io::Error::other(e.to_string()))
                    })
                    .map_err(|e| format!("write_input failed: {e}"))?;
                // Ack exactly-once: both Applied and Deduped mean "durably landed".
                send_frame(send, &applied_envelope(req.session_id, op, outcome == Outcome::Applied));
                Ok(())
            }
            None => session.write_input(&bytes).map_err(|e| format!("write_input failed: {e}")),
        }
    }

    /// Resize a session's PTY.
    fn dispatch_resize(&self, env: Envelope) -> Result<(), String> {
        let req: ResizeReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad resize payload: {e}"))?;
        let sessions = self.sessions.lock().unwrap();
        let h = sessions
            .get(&req.session_id)
            .ok_or_else(|| format!("no such session {}", req.session_id))?;
        h.session
            .resize(SurfaceSize::new(req.rows, req.cols))
            .map_err(|e| format!("resize failed: {e}"))
    }

    /// Report the broker-owned net endpoint's status (D4a). Answered even when
    /// the broker has no network host (`enabled: false`) so a brain can probe
    /// capability without treating absence as an error.
    // [impl->REQ-DAEMON-2]
    fn dispatch_net_status(&self, send: &SharedSend) {
        let reply = match &self.net {
            Some(host) => NetStatusReply {
                enabled: true,
                node_id_hex: Some(host.node_id_hex()),
                addr: host.addr_json(),
                conn_count: host.conn_count(),
            },
            None => NetStatusReply {
                enabled: false,
                node_id_hex: None,
                addr: serde_json::Value::Null,
                conn_count: 0,
            },
        };
        let frame = Envelope::new(
            KIND_NET_STATUS_REPLY,
            serde_json::to_value(reply).expect("NetStatusReply serializes"),
        );
        send_frame(send, &frame);
    }

    /// Dial a peer over the broker-owned endpoint (D4a).
    ///
    /// With an `op_id` the dial is routed through the broker-owned
    /// [`EffectJournal`] under the reserved [`NET_EFFECT_SESSION`] namespace,
    /// exactly like an idempotent PTY write: a replay of the same id after a
    /// brain crash is deduped (no second connection) and answered with the
    /// connection the first apply opened. The `conn_id` is recorded *inside*
    /// the journaled closure, so a concurrent deduped replay always finds it.
    /// A deduped id whose connection this process no longer holds (a broker
    /// restart — the journal survives, connections don't) is a typed error
    /// telling the brain to dial under a fresh id.
    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
    fn dispatch_net_dial(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
        let req: NetDialReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad net-dial payload: {e}"))?;
        let host = self
            .net
            .as_ref()
            .ok_or("net disabled: this broker was bound without a network host")?;
        let addr: EndpointAddr = serde_json::from_value(req.addr)
            .map_err(|e| format!("bad net-dial addr: {e}"))?;
        match req.op_id {
            None => {
                // Direct (non-journaled) dial — no dedup, like a no-op_id input.
                let (conn_id, remote) = host.dial(addr).map_err(|e| e.to_string())?;
                send_frame(send, &net_dialed_envelope(conn_id, remote, None, true));
                Ok(())
            }
            Some(op) => {
                let outcome = self
                    .journal
                    .apply_once((NET_EFFECT_SESSION, op), EffectKind::NetDial, || {
                        let (conn_id, _) = host.dial(addr)?;
                        host.record_dial_op(op, conn_id);
                        Ok(())
                    })
                    .map_err(|e| format!("net-dial failed: {e}"))?;
                let conn_id = host.dial_op_conn(op).ok_or(
                    "net-dial op already applied but its connection is no longer held \
                     (broker restarted); retry with a fresh op_id",
                )?;
                let remote = host
                    .conn_remote_hex(conn_id)
                    .ok_or("dialed connection missing from the conn table")?;
                let applied_now = outcome == Outcome::Applied;
                send_frame(send, &net_dialed_envelope(conn_id, remote, Some(op), applied_now));
                Ok(())
            }
        }
    }

    /// Open a bidi stream on a held connection (D4b). Journaled exactly-once
    /// under the same discipline as [`Self::dispatch_net_dial`]: the minted
    /// `stream_id` is recorded inside the `apply_once` closure, and a deduped
    /// replay is answered with the stream the first apply opened.
    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
    fn dispatch_net_stream_open(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
        let req: NetStreamOpenReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad net-stream-open payload: {e}"))?;
        let host = self.net.as_ref().ok_or("net disabled")?;
        let build = |stream_id: u64, conn_id: u64, op_id: Option<u64>, applied_now: bool| {
            Envelope::new(
                KIND_NET_STREAM_OPENED,
                serde_json::to_value(NetStreamOpened { stream_id, conn_id, op_id, applied_now })
                    .expect("NetStreamOpened serializes"),
            )
        };
        match req.op_id {
            None => {
                let stream_id = host.open_stream(req.conn_id).map_err(|e| e.to_string())?;
                send_frame(send, &build(stream_id, req.conn_id, None, true));
                Ok(())
            }
            Some(op) => {
                let outcome = self
                    .journal
                    .apply_once((NET_EFFECT_SESSION, op), EffectKind::NetSend, || {
                        let stream_id = host.open_stream(req.conn_id)?;
                        host.record_stream_op(op, stream_id);
                        Ok(())
                    })
                    .map_err(|e| format!("net-stream-open failed: {e}"))?;
                let stream_id = host.stream_op_id(op).ok_or(
                    "stream-open op already applied but its stream is no longer held \
                     (broker restarted); retry with a fresh op_id",
                )?;
                let applied_now = outcome == Outcome::Applied;
                send_frame(send, &build(stream_id, req.conn_id, Some(op), applied_now));
                Ok(())
            }
        }
    }

    /// Write bytes to a held stream's send side (D4b). With an `op_id` the
    /// write is exactly-once through the broker-owned journal (a replayed
    /// chunk after a brain crash is not written twice — Spike #3 invariant D
    /// on the send path) and acked with `net-sent`; without one it is a direct
    /// write (no dedup, no ack), mirroring the PTY `input` contract.
    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
    fn dispatch_net_stream_send(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
        let req: NetStreamSendReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad net-stream-send payload: {e}"))?;
        let bytes =
            decode_bytes(&req.data_b64).map_err(|e| format!("bad net-stream-send base64: {e}"))?;
        let host = self.net.as_ref().ok_or("net disabled")?;
        match req.op_id {
            None => host
                .send_stream(req.stream_id, &bytes, req.finish)
                .map_err(|e| e.to_string()),
            Some(op) => {
                let outcome = self
                    .journal
                    .apply_once((NET_EFFECT_SESSION, op), EffectKind::NetSend, || {
                        host.send_stream(req.stream_id, &bytes, req.finish)
                    })
                    .map_err(|e| format!("net-stream-send failed: {e}"))?;
                let frame = Envelope::new(
                    KIND_NET_SENT,
                    serde_json::to_value(NetSent {
                        stream_id: req.stream_id,
                        op_id: op,
                        applied_now: outcome == Outcome::Applied,
                    })
                    .expect("NetSent serializes"),
                );
                send_frame(send, &frame);
                Ok(())
            }
        }
    }

    /// List every stream the broker holds (D4b) — the query a re-attaching
    /// brain uses to find streams that arrived while it was down.
    fn dispatch_net_streams(&self, send: &SharedSend) -> Result<(), String> {
        let host = self.net.as_ref().ok_or("net disabled")?;
        let frame = Envelope::new(
            KIND_NET_STREAMS_REPLY,
            serde_json::to_value(NetStreamsReply { streams: host.stream_infos() })
                .expect("NetStreamsReply serializes"),
        );
        send_frame(send, &frame);
        Ok(())
    }

    /// Re-attach a brain to a stream's buffered reads from a cursor (D4b) —
    /// the gapless-resume `subscribe` discipline on a QUIC stream.
    // [impl->REQ-DAEMON-2]
    fn dispatch_net_stream_subscribe(
        &self,
        env: Envelope,
        send: &SharedSend,
    ) -> Result<u64, String> {
        let req: NetStreamSubscribeReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad net-stream-subscribe payload: {e}"))?;
        let host = self.net.as_ref().ok_or("net disabled")?;
        host.subscribe_stream(req.stream_id, Arc::clone(send), req.from_seq)
            .map_err(|e| e.to_string())?;
        Ok(req.stream_id)
    }

    /// Terminate a session's child. The exit waiter emits the `exit` frame and
    /// reaps the session once the child is dead.
    fn dispatch_kill(&self, env: Envelope) -> Result<(), String> {
        let req: KillReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad kill payload: {e}"))?;
        let sessions = self.sessions.lock().unwrap();
        let h = sessions
            .get(&req.session_id)
            .ok_or_else(|| format!("no such session {}", req.session_id))?;
        h.session.kill().map_err(|e| format!("kill failed: {e}"))
    }
}

/// The default broker-owned effect-journal path: `<spt_home>/daemon-effects.log`.
pub(crate) fn default_journal_path() -> std::path::PathBuf {
    spt_store::perch::spt_home().join("daemon-effects.log")
}

/// Write a frame to the brain, ignoring a broken-pipe failure (brain gone).
fn send_frame(send: &SharedSend, frame: &Envelope) {
    if let Ok(mut s) = send.lock() {
        let _ = write_frame(&mut *s, frame);
    }
}

/// Send an `error` event to the brain (best-effort).
fn send_error(send: &SharedSend, message: &str) {
    let frame = Envelope::new(
        KIND_ERROR,
        serde_json::to_value(ErrorEvent { message: message.to_string() })
            .expect("ErrorEvent serializes"),
    );
    send_frame(send, &frame);
}