Skip to main content

car_server_core/coder/
discuss.rs

1//! `coder.discuss.*` — a repo-grounded, strictly **read-only** conversation
2//! that can be distilled into a run intent.
3//!
4//! The gap this closes: `coder.start` demands a well-formed intent before
5//! anything exists to react to. An operator who is still working out *what*
6//! they want has no surface between "I have a vague idea" and "here is a
7//! contract-worthy sentence" — so they either guess (and burn a session on a
8//! badly-aimed contract) or go think somewhere else with none of the repo in
9//! front of them.
10//!
11//! A discussion is grounded in the repo through the same
12//! [`AssistantService`] that backs `car do`,
13//! bound with `bind_default_substrate(prefer_local = true, full_access = false,
14//! …)` — i.e. [`PermissionTier::ReadOnly`], where every write and every shell
15//! escalates to an approval gate.
16//!
17//! [`PermissionTier::ReadOnly`]: car_policy::permission::PermissionTier
18//!
19//! ## What is actually enforced
20//!
21//! Two independent mechanisms, both required — an earlier version of this doc
22//! claimed the discussion "never touches the repo", which overstated the first
23//! and ignored that reads were unbounded:
24//!
25//! 1. **No mutation.** `write_file`, `edit_file` and `shell` are in the
26//!    ReadOnly tier's gated set, so each escalates to the approval gate — and
27//!    this surface **auto-DENIES** every escalation rather than prompting a
28//!    human. A discussion cannot write a file, run a command, create a branch,
29//!    or provision a worktree. The refusal is visible as a
30//!    `tool_result { ok: false }`, never silent.
31//! 2. **No read *path* outside the repo.** The read tools (`read_file`,
32//!    `list_dir`, `find_files`, `grep_files`) are NOT gated — they are the
33//!    point of a grounded discussion — so mutation-gating alone left them
34//!    pointed at the whole filesystem. The discussion's bound environment
35//!    therefore sets [`BoundEnvironment::clamp_reads`], pinning those four
36//!    inside the repo root. Without it, a prompt-injected repo file could ask
37//!    for `grep_files {"path":"/Users/<user>","pattern":"sk-ant-"}` and the
38//!    hits would stream to every `coder.discuss.event` subscriber. Scoped to
39//!    this surface only; the general assistant's read reach is unchanged.
40//!
41//!    Read the claim precisely: the clamp is **lexical**, not a resolved-path
42//!    check. `coder::policy::stays_under` normalizes `.` / `..` textually and
43//!    compares the result against the root (see its own
44//!    `stays_under_is_lexical_and_strict` test), and the file walk stats
45//!    entries with `metadata()`, which follows symlinks. So a symlink
46//!    *committed inside the repo* and pointing outward reads through the clamp
47//!    — its path stays under the root, its target does not. What the clamp
48//!    stops is the model **naming** a path outside the repo, which is the
49//!    prompt-injection vector above; it is not a containment boundary against
50//!    the repo's own contents. Treat the repo as trusted-to-the-extent-you-
51//!    trust-what-is-committed-in-it.
52//!
53//! [`BoundEnvironment::clamp_reads`]: crate::assistant::BoundEnvironment::clamp_reads
54//!
55//! `coder.start` is the only thing that starts work, and
56//! `coder.discuss.promote` deliberately starts nothing — it hands back a
57//! distilled intent the operator may edit first.
58//!
59//! ## Lifetime
60//!
61//! Discussions are in-memory only and do **not** survive a daemon restart. The
62//! model thread, the bound runtime and the substrate are all process-local;
63//! persisting the transcript alone would resume a conversation whose grounding
64//! no longer exists. They are also **owned by the connection that opened
65//! them**: only that connection may send to, subscribe to, promote or close
66//! them, and closing it closes the discussion and cancels any in-flight turn,
67//! because a detached turn would keep billing model tokens to nobody. Bounded
68//! three ways — [`MAX_OPEN_DISCUSSIONS`], [`DISCUSSION_IDLE_TTL_SECS`], and the
69//! per-discussion buffer/transcript caps.
70//!
71//! ## Event fanout
72//!
73//! One **drain task per discussion** owns the subscriber set. Emits, attaches
74//! and detaches are commands on its channel, so a single task serializes them:
75//! `seq` is assigned under the buffer lock by the only writer (no out-of-order
76//! buffer), an attach replays everything buffered *before* the next emit is
77//! processed (no gap, no duplicate), and — unlike the `coder.event` path — no
78//! lock is ever held across a send.
79//!
80//! The drain does not send, though: **each subscriber owns a bounded queue and
81//! its own sender task**. That is the part that makes a wedged subscriber
82//! merely its own problem. When the drain itself performed the sends, an
83//! untimed write to a half-open socket blocked the drain, so the *next* `Emit`
84//! command sat unprocessed — and since every `entry.emit(…).await` inside
85//! [`run_turn`] waits for its `seq`, one wedged board stalled the whole
86//! **turn**, not just its stream. Now the drain only `try_send`s into each
87//! subscriber's queue: a subscriber that cannot keep up (queue full, or a send
88//! past [`DISCUSS_SEND_TIMEOUT`]) is **shed**, and the turn never waits on a
89//! socket.
90
91use std::collections::HashMap;
92use std::path::{Path, PathBuf};
93use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
94use std::sync::{Arc, Mutex as StdMutex, MutexGuard};
95
96use serde::{Deserialize, Serialize};
97use serde_json::{json, Value};
98use tokio::sync::{mpsc, oneshot};
99
100use crate::assistant::{
101    bind_default_substrate, build_assistant_runtime, prompt, AssistantConfig, AssistantService,
102};
103use crate::coder::native_loop::TurnGenerator;
104use crate::handler::JsonRpcMessage;
105use crate::session::{ClientSession, ServerState, WsChannel};
106
107/// Turn cap for one discussion reply. A discussion reads and reasons; it never
108/// edits, so it has no repair loop to spend turns on.
109const DISCUSS_MAX_TURNS: u32 = 12;
110
111/// Attempts allowed when distilling a transcript into an intent. Same bounded
112/// shape as `derive_contract`: the output is structured JSON, so a malformed
113/// reply is worth one retry, not an unbounded loop.
114const PROMOTE_MAX_ATTEMPTS: u32 = 3;
115
116/// Concurrent open discussions per daemon. Each pins an `AssistantService`, a
117/// `Runtime`, and an open runtime session, so they are not free; a board opens
118/// one at a time and an operator juggling more than a handful has lost track.
119///
120/// Enforced by [`ServerState::coder_discussion_slots`], a semaphore whose
121/// permit is taken before any of `start_discussion`'s async work and lives
122/// inside the admitted [`DiscussionEntry`] — NOT by counting the registry, which
123/// was a TOCTOU check that bounded nothing under pipelined starts.
124///
125/// [`ServerState::coder_discussion_slots`]: crate::session::ServerState
126pub(crate) const MAX_OPEN_DISCUSSIONS: usize = 8;
127
128/// A discussion with no activity for this long is reaped on the next
129/// `coder.discuss.start`. Long enough to step away from a train of thought,
130/// short enough that a forgotten one does not pin a runtime overnight.
131const DISCUSSION_IDLE_TTL_SECS: u64 = 60 * 60;
132
133/// Retained events per discussion. The oldest are dropped past this; a replay
134/// from a trimmed cursor returns what survives (`events_replayed` says how
135/// much) rather than growing without bound on a long conversation.
136const DISCUSS_EVENT_BUFFER_MAX: usize = 2000;
137
138/// Transcript turns retained for distillation. `promote` is a summarization
139/// call, so the recent exchange is what carries the intent; keeping everything
140/// eventually builds a prompt no model window holds.
141const TRANSCRIPT_MAX_TURNS: usize = 40;
142
143/// Turns handed to `distill`. The most recent slice of the retained transcript
144/// — the tail is where the operator converged.
145const DISTILL_WINDOW_TURNS: usize = 12;
146
147/// Byte cap on one operator message.
148///
149/// tungstenite accepts up to 64 MiB per frame, and an accepted message is
150/// cloned into the transcript, cloned again into the event buffer, and rendered
151/// into the distill prompt — so without a cap, 40 sequential 50 MB sends retain
152/// gigabytes per discussion and make `promote` build a prompt no window holds.
153/// `summarize_repo` is head-capped for exactly this reason; operator text needs
154/// the same. Generous for prose — this is a conversation, not a file upload.
155const DISCUSS_MESSAGE_MAX_BYTES: usize = 64 * 1024;
156
157/// Depth of one subscriber's outbound frame queue.
158///
159/// Must exceed [`DISCUSS_EVENT_BUFFER_MAX`] so a legitimate
160/// `subscribe { from_seq: 0 }` replay — up to a full buffer, queued in one go —
161/// is never mistaken for a slow consumer. Past that, a subscriber this far
162/// behind is not reading.
163const DISCUSS_SUBSCRIBER_QUEUE: usize = DISCUSS_EVENT_BUFFER_MAX + 128;
164
165/// How long one frame may take to reach a subscriber's socket before that
166/// subscriber is shed. A half-open peer never fails a write — it parks forever,
167/// holding the socket's write half. Matches the coder fanout's deadline.
168const DISCUSS_SEND_TIMEOUT: std::time::Duration = crate::coder::rpc::FANOUT_WRITE_TIMEOUT;
169
170/// Live discussions keyed by `discussion_id`.
171pub type DiscussionMap = HashMap<String, Arc<DiscussionEntry>>;
172
173/// Take a `std` lock without letting a poisoned mutex become permanent.
174///
175/// A panic anywhere under one of these locks would otherwise brick the
176/// discussion for its whole lifetime — and the first panic is swallowed by the
177/// detached turn task, so the operator would see an inexplicably dead
178/// conversation with no error. The data behind each of these is a plain
179/// `Vec`/`Option`; a torn write is not a safety problem here.
180fn lock<T>(m: &StdMutex<T>) -> MutexGuard<'_, T> {
181    m.lock().unwrap_or_else(|e| e.into_inner())
182}
183
184/// One event in a discussion's stream. `seq` is monotonic per discussion so a
185/// client can resume from a cursor, exactly like `CoderEvent`.
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct DiscussEvent {
188    pub discussion_id: String,
189    pub seq: u64,
190    pub ts: u64,
191    #[serde(flatten)]
192    pub kind: DiscussEventKind,
193}
194
195/// What happened in a discussion. Serialized with `"type":"snake_case_name"`,
196/// tagged the same way [`CoderEventKind`](super::session::CoderEventKind) is.
197#[derive(Debug, Clone, Serialize, Deserialize)]
198#[serde(tag = "type", rename_all = "snake_case")]
199pub enum DiscussEventKind {
200    UserMessage {
201        text: String,
202    },
203    /// A streaming chunk of the model's reply.
204    AssistantDelta {
205        text: String,
206    },
207    /// The complete assistant turn.
208    AssistantMessage {
209        text: String,
210    },
211    ToolCall {
212        tool: String,
213        params_preview: String,
214    },
215    ToolResult {
216        tool: String,
217        ok: bool,
218        preview: String,
219    },
220    TurnComplete {},
221    Error {
222        message: String,
223    },
224}
225
226/// A command for a discussion's drain task — the single owner of its
227/// subscriber set and the only thing that writes its buffer or sends a frame.
228enum StreamCmd {
229    Emit(DiscussEventKind, oneshot::Sender<u64>),
230    Attach {
231        client_id: String,
232        channel: Arc<WsChannel>,
233        from_seq: u64,
234        replayed: oneshot::Sender<u64>,
235    },
236    Detach(String),
237}
238
239/// The in-flight turn's handle and the discussion's terminal `closed` latch,
240/// deliberately behind one lock.
241///
242/// They were separate, and the gap between them orphaned model loops: `close`
243/// read `turn_task` (still `None`, because `send_message` stores the handle
244/// only *after* its first `emit().await`), found nothing to abort, and removed
245/// the entry from the registry — then `send_message` resumed and spawned a turn
246/// against a discussion nothing could reach any more. It billed up to
247/// [`DISCUSS_MAX_TURNS`] turns against a live provider with no way to stop it.
248/// Publishing "this discussion is closed" and "here is the turn to abort"
249/// through the same lock closes that window in both directions: a close either
250/// aborts the running turn or latches `closed` so the turn is never spawned.
251#[derive(Default)]
252struct TurnSlot {
253    /// Set once, terminally, by [`DiscussionEntry::cancel_turn`]. Every caller
254    /// of `cancel_turn` also removes the entry from the registry, so there is
255    /// no legitimate reopen.
256    closed: bool,
257    handle: Option<tokio::task::JoinHandle<()>>,
258}
259
260/// Clears `in_flight` on EVERY exit path, including a cancelled or panicking
261/// handler future.
262///
263/// `in_flight` was set by CAS in `send_message` and cleared only at the tail of
264/// the spawned turn task. Anything that dropped the handler future between
265/// those two points — the daemon's handler deadline is the reachable one, since
266/// `coder.discuss.send` is not deadline-exempt — left it `true` with no turn
267/// running. The discussion then answered "still answering the previous message"
268/// to every `send` and "still answering" to every `promote`, forever, and
269/// `reap_idle` runs only on the next `discuss.start`, so on a quiet daemon it
270/// was never reclaimed either. A latch that only one code path can release is
271/// a latch that leaks; this releases in `Drop`.
272struct InFlightGuard(Arc<DiscussionEntry>);
273
274impl Drop for InFlightGuard {
275    fn drop(&mut self) {
276        self.0.in_flight.store(false, Ordering::SeqCst);
277        self.0.touch();
278    }
279}
280
281/// Keeps the operator's turn in the transcript only if a reply turn was
282/// actually dispatched for it.
283///
284/// `send_message` records the turn before the `emit().await` it may be
285/// cancelled at, and before the dispatch that may be refused. `InFlightGuard`
286/// frees the discussion on those paths, but the transcript was left ending in
287/// an operator question with no reply — and that is exactly the input the
288/// `is_answering()` guards on `promote` and `coder.start { discussion_id }`
289/// exist to keep out of distillation. Those guards read "not answering", so a
290/// stranded question sails through them and the model invents a confident
291/// intent from a question nobody answered. Recording after the dispatch would
292/// let the spawned turn's `Assistant` row land first, so the row goes in early
293/// and comes back out on every path that did not dispatch.
294struct TurnRecordGuard {
295    entry: Arc<DiscussionEntry>,
296    text: String,
297    dispatched: bool,
298}
299
300impl Drop for TurnRecordGuard {
301    fn drop(&mut self) {
302        if !self.dispatched {
303            self.entry.rollback_turn("Operator", &self.text);
304        }
305    }
306}
307
308/// One live discussion.
309pub struct DiscussionEntry {
310    pub id: String,
311    /// The git repo the conversation is grounded in.
312    pub repo: PathBuf,
313    /// Cheap repo orientation, returned by `coder.discuss.start` so a caller
314    /// can show what the discussion can see.
315    pub repo_summary: String,
316    pub created_at: u64,
317    /// The connection that opened this discussion. Closing it closes the
318    /// discussion — see the module docs on lifetime.
319    owner_client_id: String,
320    /// Replay buffer. Written **only** by the drain task, so it is always in
321    /// `seq` order; readable elsewhere for inspection.
322    pub events: Arc<tokio::sync::Mutex<Vec<DiscussEvent>>>,
323    /// Commands to the drain task.
324    cmds: mpsc::UnboundedSender<StreamCmd>,
325    /// Completed operator turns (what `turns` reports in `coder.discuss.list`).
326    turns: AtomicU64,
327    /// Whether a reply turn is running right now. A discussion is a
328    /// conversation: two overlapping turns interleave into one model thread and
329    /// silently lose one of them, so a second `send` is refused rather than
330    /// queued.
331    in_flight: AtomicBool,
332    /// Last activity, for the idle TTL.
333    last_active: AtomicU64,
334    /// The in-flight turn's task plus the terminal `closed` latch, under ONE
335    /// lock. See [`TurnSlot`] for why they cannot be separate.
336    turn_task: StdMutex<TurnSlot>,
337    /// This discussion's open-slot reservation, taken before any of
338    /// `start_discussion`'s async work and released when the entry drops.
339    _slot: tokio::sync::OwnedSemaphorePermit,
340    /// The grounded, read-only conversational service.
341    service: Arc<AssistantService>,
342    /// The model seam used for distillation (`promote`). Same injection style
343    /// `derive_contract` uses, so promote is testable with a scripted model.
344    generator: Arc<dyn TurnGenerator>,
345    /// Role-tagged plain-text transcript, kept for distillation. Deliberately
346    /// separate from the service's own message thread: promote must see the
347    /// conversation, not the tool plumbing. Capped at [`TRANSCRIPT_MAX_TURNS`].
348    transcript: StdMutex<Vec<(&'static str, String)>>,
349    /// The most recent `promote` result, cached so `coder.start
350    /// { discussion_id }` can fold the agreed constraints into contract
351    /// derivation without a second distillation call.
352    last_promote: StdMutex<Option<(String, Vec<String>)>>,
353}
354
355impl DiscussionEntry {
356    /// Constraints agreed in this discussion, from the last `promote`.
357    pub fn constraints(&self) -> Vec<String> {
358        lock(&self.last_promote)
359            .as_ref()
360            .map(|(_, c)| c.clone())
361            .unwrap_or_default()
362    }
363
364    /// Whether a reply turn is running right now.
365    pub fn is_answering(&self) -> bool {
366        self.in_flight.load(Ordering::SeqCst)
367    }
368
369    fn touch(&self) {
370        self.last_active.store(now_secs(), Ordering::SeqCst);
371    }
372
373    fn idle_secs(&self) -> u64 {
374        now_secs().saturating_sub(self.last_active.load(Ordering::SeqCst))
375    }
376
377    fn record_turn(&self, role: &'static str, text: &str) {
378        if text.trim().is_empty() {
379            return;
380        }
381        let mut t = lock(&self.transcript);
382        t.push((role, text.to_string()));
383        // Bounded: drop from the front, keeping the recent exchange.
384        let len = t.len();
385        if len > TRANSCRIPT_MAX_TURNS {
386            t.drain(..len - TRANSCRIPT_MAX_TURNS);
387        }
388    }
389
390    /// Undo the most recent [`record_turn`](Self::record_turn) when it is still
391    /// the tail and still ours. Matching on both role and text is what keeps a
392    /// rollback from eating someone else's row if the transcript moved on.
393    fn rollback_turn(&self, role: &'static str, text: &str) {
394        let mut t = lock(&self.transcript);
395        if t.last().is_some_and(|(r, s)| *r == role && s == text) {
396            t.pop();
397        }
398    }
399
400    /// The most recent turns, rendered for distillation.
401    fn distill_transcript(&self) -> String {
402        let t = lock(&self.transcript);
403        let start = t.len().saturating_sub(DISTILL_WINDOW_TURNS);
404        t[start..]
405            .iter()
406            .map(|(role, text)| format!("{role}: {text}"))
407            .collect::<Vec<_>>()
408            .join("\n\n")
409    }
410
411    fn transcript_is_empty(&self) -> bool {
412        lock(&self.transcript).is_empty()
413    }
414
415    /// Append an event to the stream, returning its assigned `seq`.
416    ///
417    /// The drain assigns the seq under the buffer lock, so the buffer is always
418    /// ordered; this only waits for that assignment, never for a WS send.
419    async fn emit(&self, kind: DiscussEventKind) -> u64 {
420        let (tx, rx) = oneshot::channel();
421        if self.cmds.send(StreamCmd::Emit(kind, tx)).is_err() {
422            return 0; // drain gone (discussion closed) — nothing to stream to
423        }
424        rx.await.unwrap_or(0)
425    }
426
427    /// Stop an in-flight turn and latch the discussion closed: signal the loop,
428    /// drop the task, and make sure no turn that is still being dispatched can
429    /// start behind us.
430    ///
431    /// Terminal by construction — every caller (`close`, disconnect teardown,
432    /// `reap_idle`) also removes the entry from the registry.
433    fn cancel_turn(&self) {
434        self.service.cancel(&self.id);
435        {
436            let mut slot = lock(&self.turn_task);
437            slot.closed = true;
438            if let Some(handle) = slot.handle.take() {
439                handle.abort();
440            }
441        }
442        self.in_flight.store(false, Ordering::SeqCst);
443    }
444
445    /// Spawn the reply turn under the same lock `cancel_turn` latches, so a
446    /// close that raced the dispatch either aborts this turn or prevents it.
447    ///
448    /// Returns `false` when the discussion was closed before the dispatch
449    /// reached this point — the turn is then never spawned at all.
450    fn spawn_turn<F>(&self, make: F) -> bool
451    where
452        F: FnOnce() -> tokio::task::JoinHandle<()>,
453    {
454        let mut slot = lock(&self.turn_task);
455        if slot.closed {
456            return false;
457        }
458        // No await under this guard: `tokio::spawn` only queues the task.
459        slot.handle = Some(make());
460        true
461    }
462
463    fn summary_row(&self) -> Value {
464        json!({
465            "discussion_id": self.id,
466            "repo": self.repo,
467            "created_at": self.created_at,
468            "turns": self.turns.load(Ordering::SeqCst),
469        })
470    }
471}
472
473fn now_secs() -> u64 {
474    std::time::SystemTime::now()
475        .duration_since(std::time::UNIX_EPOCH)
476        .map(|d| d.as_secs())
477        .unwrap_or(0)
478}
479
480fn event_frame(event: &DiscussEvent) -> Option<String> {
481    serde_json::to_string(&json!({
482        "jsonrpc": "2.0",
483        "method": "coder.discuss.event",
484        "params": event,
485    }))
486    .ok()
487}
488
489/// One subscriber's outbound lane: a bounded frame queue plus the task that
490/// drains it onto that subscriber's socket.
491///
492/// One lane per subscriber is what decouples the stream from the turn. The
493/// drain hands frames over with `try_send` and never awaits a socket, so no
494/// subscriber can delay the `seq` reply the turn is blocked on.
495struct Subscriber {
496    frames: mpsc::Sender<String>,
497    task: tokio::task::JoinHandle<()>,
498}
499
500impl Drop for Subscriber {
501    /// Abort rather than let the queue drain: the task may be parked on a
502    /// half-open socket's write mutex, and that parked future is precisely what
503    /// keeps the write half alive past teardown.
504    fn drop(&mut self) {
505        self.task.abort();
506    }
507}
508
509fn spawn_subscriber(channel: Arc<WsChannel>) -> Subscriber {
510    let (frames, mut rx) = mpsc::channel::<String>(DISCUSS_SUBSCRIBER_QUEUE);
511    let task = tokio::spawn(async move {
512        while let Some(frame) = rx.recv().await {
513            if tokio::time::timeout(
514                DISCUSS_SEND_TIMEOUT,
515                crate::coder::rpc::send_frame(&channel, &frame),
516            )
517            .await
518            .is_err()
519            {
520                // Wedged socket. Ending the task drops the channel handle and
521                // closes the queue, so the drain sheds this subscriber on its
522                // next `try_send` instead of queueing for a peer that is gone.
523                break;
524            }
525        }
526    });
527    Subscriber { frames, task }
528}
529
530/// The per-discussion drain: the single writer of the buffer and the single
531/// owner of the subscriber set.
532///
533/// Because one task handles emits and attaches in order, an attach replays
534/// everything buffered so far and is registered before the next emit is
535/// processed — no gap and no duplicate — without holding any lock across a
536/// handoff. The drain itself never touches a socket: it `try_send`s into each
537/// subscriber's own queue, so a subscriber that has stopped reading is shed
538/// rather than allowed to stall the buffer, the next `Emit`, or the turn
539/// waiting on that `Emit`'s `seq`.
540fn spawn_discuss_drain(
541    discussion_id: String,
542    events: Arc<tokio::sync::Mutex<Vec<DiscussEvent>>>,
543) -> mpsc::UnboundedSender<StreamCmd> {
544    let (tx, mut rx) = mpsc::unbounded_channel::<StreamCmd>();
545    tokio::spawn(async move {
546        let mut subscribers: HashMap<String, Subscriber> = HashMap::new();
547        let mut next_seq: u64 = 0;
548        while let Some(cmd) = rx.recv().await {
549            match cmd {
550                StreamCmd::Emit(kind, reply) => {
551                    let seq = next_seq;
552                    next_seq += 1;
553                    let event = DiscussEvent {
554                        discussion_id: discussion_id.clone(),
555                        seq,
556                        ts: now_secs(),
557                        kind,
558                    };
559                    let frame = event_frame(&event);
560                    {
561                        let mut buffer = events.lock().await;
562                        buffer.push(event);
563                        let len = buffer.len();
564                        if len > DISCUSS_EVENT_BUFFER_MAX {
565                            buffer.drain(..len - DISCUSS_EVENT_BUFFER_MAX);
566                        }
567                    } // lock released BEFORE the handoff
568                    let _ = reply.send(seq);
569                    if let Some(frame) = &frame {
570                        // `try_send`, never `send`: a full queue means this
571                        // subscriber is not draining, and waiting for it is how
572                        // one wedged board used to stall the whole turn.
573                        subscribers.retain(|client_id, s| {
574                            let ok = s.frames.try_send(frame.clone()).is_ok();
575                            if !ok {
576                                tracing::warn!(
577                                    discussion_id = %discussion_id,
578                                    client_id = %client_id,
579                                    "discussion subscriber is not draining; dropping it"
580                                );
581                            }
582                            ok
583                        });
584                    }
585                }
586                StreamCmd::Attach {
587                    client_id,
588                    channel,
589                    from_seq,
590                    replayed,
591                } => {
592                    // Clone the frames under the lock, release, then queue.
593                    let frames: Vec<String> = {
594                        let buffer = events.lock().await;
595                        buffer
596                            .iter()
597                            .filter(|e| e.seq >= from_seq)
598                            .filter_map(event_frame)
599                            .collect()
600                    };
601                    let subscriber = spawn_subscriber(channel);
602                    // The queue is sized to hold a whole buffer replay, so this
603                    // only short-circuits if the peer's lane already died.
604                    let mut n = 0u64;
605                    for frame in frames {
606                        if subscriber.frames.try_send(frame).is_err() {
607                            break;
608                        }
609                        n += 1;
610                    }
611                    subscribers.insert(client_id, subscriber);
612                    let _ = replayed.send(n);
613                }
614                StreamCmd::Detach(client_id) => {
615                    subscribers.remove(&client_id);
616                }
617            }
618        }
619        // Discussion closed: every lane's task is aborted by `Subscriber::drop`.
620    });
621    tx
622}
623
624// ---------------------------------------------------------------------------
625// Orchestration (generation-injectable, transport-free)
626// ---------------------------------------------------------------------------
627
628/// Provision a discussion grounded in `repo`, owned by `owner_client_id`.
629///
630/// `engine` builds the read-only assistant runtime (tools, substrate, gates);
631/// `generator` is the model seam both the conversation and `promote` run on.
632/// Split so tests can drive a scripted model against a real temp repo.
633pub async fn start_discussion(
634    state: &Arc<ServerState>,
635    repo: &Path,
636    owner_client_id: &str,
637    engine: Arc<car_inference::InferenceEngine>,
638    generator: Arc<dyn TurnGenerator>,
639) -> Result<Value, String> {
640    // `canonicalize` and the `git rev-parse` probe are blocking syscalls (the
641    // probe forks), so they go to a blocking worker rather than parking a tokio
642    // runtime thread on fork/exec.
643    let probe = repo.to_path_buf();
644    let repo = tokio::task::spawn_blocking(move || {
645        let repo = probe
646            .canonicalize()
647            .map_err(|e| format!("repo path {}: {e}", probe.display()))?;
648        if !super::rpc::is_git_repo(&repo) {
649            return Err(format!(
650                "{} is not a git repository — discuss needs a repo to ground itself in",
651                repo.display()
652            ));
653        }
654        Ok(repo)
655    })
656    .await
657    .map_err(|e| format!("repo probe failed: {e}"))??;
658
659    // Reap idle discussions before enforcing the cap, so a forgotten one from
660    // this morning never blocks a new one this afternoon.
661    reap_idle(state).await;
662    // RESERVE the slot before any of the work below. Counting the registry here
663    // and inserting after `bind_default_substrate` + `build_assistant_runtime`
664    // was a TOCTOU check: the daemon runs a connection's requests concurrently,
665    // so N pipelined starts all read the same count, all passed, and all built
666    // a runtime — the cap bounded nothing. The permit lives in the entry and
667    // comes back if any step below fails.
668    let slot = state
669        .coder_discussion_slots
670        .clone()
671        .try_acquire_owned()
672        .map_err(|_| {
673            format!(
674                "{MAX_OPEN_DISCUSSIONS} discussions are already open — close one with \
675                 coder.discuss.close before starting another"
676            )
677        })?;
678
679    let summarize = repo.clone();
680    let repo_summary = tokio::task::spawn_blocking(move || super::rpc::summarize_repo(&summarize))
681        .await
682        .map_err(|e| format!("repo summary failed: {e}"))?;
683
684    // prefer_local = true, full_access = false ⇒ PermissionTier::ReadOnly:
685    // every write/shell escalates to the approval gate, which this surface
686    // auto-denies (see the `approval_pending` arm in `run_turn`). No Docker
687    // preflight either — a discussion must open promptly.
688    let mut env = bind_default_substrate(true, false, &repo, None).await;
689    // ...and the read tools are pinned to the repo too. Mutation-gating alone
690    // left `read_file`/`list_dir`/`find_files`/`grep_files` pointed at the
691    // whole filesystem, whose output streams to every subscriber.
692    env.clamp_reads = true;
693    let asm = // Read-only, RPC-driven discussion: no delegating sub-agents here.
694    build_assistant_runtime(engine, env, None, None, None, None, false).await?;
695    let system = format!(
696        "{}\n\nYou are in a DISCUSSION about this repository, not a work session. \
697         You have read-only access, scoped to this repository: you can read and reason \
698         about the code here, but any attempt to write a file, run a shell command, or \
699         read outside {} WILL be refused. Do not propose to make the change yourself — \
700         help the operator decide what the change should be, what it must not break, and \
701         how they would know it worked. Be concrete and cite real paths from the repo.",
702        prompt::chat_prompt(&asm.identity, &asm.description, &asm.tools),
703        repo.display()
704    );
705    let cfg = AssistantConfig {
706        model: None,
707        strict_model: false,
708        max_turns: DISCUSS_MAX_TURNS,
709        tools: asm.tools.clone(),
710        gated_tools: asm.gated_tools.clone(),
711        approval_policy: None,
712        // A discussion writes nothing — including durable memory. Leaving the
713        // proactive-memory bank unbound keeps `remember` out of the loop's
714        // automatic pass; the tool itself is gated and auto-denied anyway.
715        proactive_memory: None,
716        tool_memory: None,
717        tool_labels: None,
718        // A discussion has no task list: it executes nothing, so there is no
719        // run for #814's per-turn state block to describe.
720        todos: None,
721        // The shipped default, like every other production call site — #813's
722        // A/B has been run and chose it; a discussion is not where that gets
723        // re-decided.
724        value_store_previews: crate::assistant::agent_loop::VALUE_STORE_PREVIEWS_DEFAULT,
725        response_format: None,
726        context_window_override: None,
727        refuse_unadvertised_tools: false,
728        response_format_validator: None,
729        delegate_budget: None,
730    };
731    let service = Arc::new(AssistantService::new(
732        generator.clone(),
733        Arc::new(asm.runtime),
734        cfg,
735        system,
736    ));
737
738    let id = format!("disc-{}", uuid::Uuid::new_v4().simple());
739    let events = Arc::new(tokio::sync::Mutex::new(Vec::new()));
740    let cmds = spawn_discuss_drain(id.clone(), events.clone());
741    let entry = Arc::new(DiscussionEntry {
742        id: id.clone(),
743        repo: repo.clone(),
744        repo_summary: repo_summary.clone(),
745        created_at: now_secs(),
746        owner_client_id: owner_client_id.to_string(),
747        events,
748        cmds,
749        turns: AtomicU64::new(0),
750        in_flight: AtomicBool::new(false),
751        last_active: AtomicU64::new(now_secs()),
752        turn_task: StdMutex::new(TurnSlot::default()),
753        _slot: slot,
754        service,
755        generator,
756        transcript: StdMutex::new(Vec::new()),
757        last_promote: StdMutex::new(None),
758    });
759    state
760        .coder_discussions
761        .lock()
762        .await
763        .insert(id.clone(), entry);
764
765    Ok(json!({
766        "discussion_id": id,
767        "repo": repo,
768        "repo_summary": repo_summary,
769    }))
770}
771
772/// Close discussions idle past [`DISCUSSION_IDLE_TTL_SECS`].
773async fn reap_idle(state: &Arc<ServerState>) {
774    let stale: Vec<Arc<DiscussionEntry>> = {
775        let open = state.coder_discussions.lock().await;
776        open.values()
777            .filter(|e| e.idle_secs() > DISCUSSION_IDLE_TTL_SECS)
778            .cloned()
779            .collect()
780    };
781    for entry in stale {
782        entry.cancel_turn();
783        state.coder_discussions.lock().await.remove(&entry.id);
784    }
785}
786
787async fn get_discussion(
788    state: &Arc<ServerState>,
789    discussion_id: &str,
790) -> Result<Arc<DiscussionEntry>, String> {
791    state
792        .coder_discussions
793        .lock()
794        .await
795        .get(discussion_id)
796        .cloned()
797        .ok_or_else(|| {
798            format!(
799                "no open discussion '{discussion_id}' — discussions are in-memory and do not \
800                 survive a daemon restart; start a new one with coder.discuss.start"
801            )
802        })
803}
804
805/// Resolve a discussion **and prove the caller owns it**.
806///
807/// Ownership was recorded but only ever consulted by disconnect teardown, so
808/// every `coder.discuss.*` method resolved by id alone: any connected client
809/// could send into, subscribe to, promote, or close another connection's
810/// discussion — closing one mid-turn was the sharp end, since it cancels a turn
811/// the owner is watching. Discussions are already per-connection and die with
812/// their connection, so refusing here is the same model, enforced.
813pub(crate) async fn get_owned_discussion(
814    state: &Arc<ServerState>,
815    discussion_id: &str,
816    client_id: &str,
817) -> Result<Arc<DiscussionEntry>, String> {
818    let entry = get_discussion(state, discussion_id).await?;
819    if entry.owner_client_id != client_id {
820        return Err(format!(
821            "discussion '{discussion_id}' belongs to another connection — a discussion is \
822             owned by the connection that opened it and closes with it; start your own with \
823             coder.discuss.start"
824        ));
825    }
826    Ok(entry)
827}
828
829/// Send one operator message and run the reply turn.
830///
831/// Returns once the turn is dispatched and has emitted its first event,
832/// carrying that event's `seq` (the `user_message`), so a caller that has not
833/// yet subscribed can resume from exactly there without missing or replaying a
834/// frame. A refused dispatch emits nothing at all.
835///
836/// **One turn at a time.** A `send` arriving while a turn is in flight is
837/// REFUSED, not queued: both turns clone the same model thread and the last one
838/// to finish overwrites the other, so the earlier exchange vanishes from the
839/// conversation — and from what `promote` later distills. Refusing is the
840/// honest answer; the caller retries when `turn_complete` lands.
841pub async fn send_message(
842    state: &Arc<ServerState>,
843    discussion_id: &str,
844    client_id: &str,
845    text: &str,
846) -> Result<Value, String> {
847    let entry = get_owned_discussion(state, discussion_id, client_id).await?;
848    if text.trim().is_empty() {
849        return Err("discuss message is empty".to_string());
850    }
851    if text.len() > DISCUSS_MESSAGE_MAX_BYTES {
852        return Err(format!(
853            "that message is {} bytes; the limit is {DISCUSS_MESSAGE_MAX_BYTES}. A discussion \
854             keeps every message in its transcript, its replay buffer, and its distillation \
855             prompt — point at a file in the repo instead of pasting it",
856            text.len()
857        ));
858    }
859    if entry
860        .in_flight
861        .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
862        .is_err()
863    {
864        return Err(format!(
865            "{discussion_id} is still answering the previous message — wait for \
866             `turn_complete` before sending another"
867        ));
868    }
869    // Armed IMMEDIATELY after the CAS: if this future is dropped before the
870    // turn owns it, the guard's Drop is the only thing that stops the
871    // discussion latching "still answering" forever with nothing running.
872    //
873    // Held in an `Option` so a REFUSED dispatch leaves it here rather than
874    // dropping it inside the `spawn_turn(…)` expression: it then drops at this
875    // function's scope exit, AFTER `recorded` (declared below, so it drops
876    // first) has rolled the transcript row back. Otherwise `in_flight` reads
877    // false while the stranded operator row is still visible — the reverse of
878    // the cancellation path's order.
879    let mut guard = Some(InFlightGuard(entry.clone()));
880    entry.touch();
881    entry.record_turn("Operator", text);
882    // ...and armed with it, for the same reason: a dispatch refused by a racing
883    // `close` must not leave the transcript ending in an operator question no
884    // turn will ever answer.
885    let mut recorded = TurnRecordGuard {
886        entry: entry.clone(),
887        text: text.to_string(),
888        dispatched: false,
889    };
890
891    let task_entry = entry.clone();
892    let prompt_text = text.to_string();
893    // The `user_message` is emitted INSIDE the turn, as its first act — not
894    // here, before the dispatch is known to have happened. Emitting it first
895    // put it in the replay buffer and on every subscriber even when the
896    // dispatch was refused and `TurnRecordGuard` rolled the transcript row
897    // back: the board then rendered the operator's question followed by
898    // permanent silence. Emitting from the turn makes the event and the
899    // transcript row commit or roll back together, and makes the ordering
900    // (`user_message` before any assistant delta for this turn) structural
901    // rather than a scheduling accident.
902    let (seq_tx, seq_rx) = oneshot::channel::<u64>();
903    // Spawned under the turn-slot lock, so a `close` that raced this dispatch
904    // either aborts the turn or stops it being spawned at all.
905    let dispatched = entry.spawn_turn(|| {
906        let guard = guard.take();
907        tokio::spawn(async move {
908            // The guard moves into the turn; it releases `in_flight` when the
909            // turn ends, is aborted, or panics.
910            let _guard = guard;
911            let seq = task_entry
912                .emit(DiscussEventKind::UserMessage {
913                    text: prompt_text.clone(),
914                })
915                .await;
916            let _ = seq_tx.send(seq);
917            run_turn(task_entry, prompt_text).await;
918        })
919    });
920    if !dispatched {
921        return Err(format!(
922            "{discussion_id} was closed while your message was being dispatched — nothing is \
923             running; start a new discussion"
924        ));
925    }
926    // A turn is running for this message now, so the transcript row stays.
927    recorded.dispatched = true;
928
929    // The turn's first event, reported so a caller that has not yet subscribed
930    // can resume from exactly there. 0 if the turn was aborted before it got
931    // that far — same answer `emit` gives when the drain is already gone.
932    let first_seq = seq_rx.await.unwrap_or(0);
933    Ok(json!({ "ok": true, "seq": first_seq }))
934}
935
936/// Drive one assistant turn, translating its wire events into discussion
937/// events and auto-denying every approval escalation.
938async fn run_turn(entry: Arc<DiscussionEntry>, text: String) {
939    let sink_entry = entry.clone();
940    let assembled: Arc<StdMutex<String>> = Arc::new(StdMutex::new(String::new()));
941    let sink_assembled = assembled.clone();
942
943    let service = entry.service.clone();
944    // The sink resolves approvals on the same service it streams from, so it
945    // needs its own handle rather than borrowing the one being called.
946    let sink_service = service.clone();
947    let id = entry.id.clone();
948    service
949        .handle_turn(&id, &text, None, move |payload: Value| {
950            let entry = sink_entry.clone();
951            let assembled = sink_assembled.clone();
952            let service = sink_service.clone();
953            async move {
954                let kind = payload.get("kind").and_then(Value::as_str).unwrap_or("");
955                match kind {
956                    "token" => {
957                        let delta = payload
958                            .get("delta")
959                            .and_then(Value::as_str)
960                            .unwrap_or_default()
961                            .to_string();
962                        if delta.is_empty() {
963                            return;
964                        }
965                        lock(&assembled).push_str(&delta);
966                        entry
967                            .emit(DiscussEventKind::AssistantDelta { text: delta })
968                            .await;
969                    }
970                    "tool_call" => {
971                        let tool = payload
972                            .get("tool")
973                            .and_then(Value::as_str)
974                            .unwrap_or("tool")
975                            .to_string();
976                        let params_preview = payload
977                            .get("params")
978                            .map(|p| preview(&p.to_string()))
979                            .unwrap_or_default();
980                        entry
981                            .emit(DiscussEventKind::ToolCall {
982                                tool,
983                                params_preview,
984                            })
985                            .await;
986                    }
987                    // The no-mutation boundary, enforced here rather than left
988                    // to a human: a discussion never writes, so an escalation is
989                    // answered immediately with "no" instead of parking a
990                    // prompt nobody asked for (and timing out five minutes
991                    // later, which is what the unresolved gate would do).
992                    "approval_pending" => {
993                        let tool = payload
994                            .get("tool")
995                            .and_then(Value::as_str)
996                            .unwrap_or("tool")
997                            .to_string();
998                        if let Some(approval_id) =
999                            payload.get("approval_id").and_then(Value::as_str)
1000                        {
1001                            service.resolve_approval(approval_id, false);
1002                        }
1003                        entry
1004                            .emit(DiscussEventKind::ToolResult {
1005                                tool,
1006                                ok: false,
1007                                preview: "refused: a discussion is read-only — it cannot write \
1008                                          files or run commands. Describe the change instead; \
1009                                          `coder.start` is what performs it."
1010                                    .to_string(),
1011                            })
1012                            .await;
1013                    }
1014                    "done" => {
1015                        let text = payload
1016                            .get("text")
1017                            .and_then(Value::as_str)
1018                            .unwrap_or_default()
1019                            .to_string();
1020                        let text = if text.trim().is_empty() {
1021                            lock(&assembled).clone()
1022                        } else {
1023                            text
1024                        };
1025                        entry.record_turn("Assistant", &text);
1026                        entry.turns.fetch_add(1, Ordering::SeqCst);
1027                        entry
1028                            .emit(DiscussEventKind::AssistantMessage { text })
1029                            .await;
1030                        entry.emit(DiscussEventKind::TurnComplete {}).await;
1031                    }
1032                    "error" => {
1033                        let message = payload
1034                            .get("error")
1035                            .and_then(Value::as_str)
1036                            .unwrap_or("discussion turn failed")
1037                            .to_string();
1038                        entry.emit(DiscussEventKind::Error { message }).await;
1039                        entry.emit(DiscussEventKind::TurnComplete {}).await;
1040                    }
1041                    _ => {}
1042                }
1043            }
1044        })
1045        .await;
1046}
1047
1048fn preview(s: &str) -> String {
1049    const CAP: usize = 200;
1050    if s.chars().count() <= CAP {
1051        return s.to_string();
1052    }
1053    let mut out: String = s.chars().take(CAP).collect();
1054    out.push('…');
1055    out
1056}
1057
1058/// Distill the discussion into a run intent + the constraints agreed in it.
1059///
1060/// **Starts nothing.** No worktree, no branch, no session — the caller shows
1061/// `proposed_intent` to the operator, who may edit it before calling
1062/// `coder.start`. Callable repeatedly on an open discussion.
1063///
1064/// Refuses while a turn is streaming: distilling then would run on the
1065/// operator's question with no answer beside it, and the model would happily
1066/// invent a confident intent from an unanswered question — which then feeds
1067/// `coder.start { discussion_id }` and contract derivation.
1068pub async fn promote(
1069    state: &Arc<ServerState>,
1070    discussion_id: &str,
1071    client_id: &str,
1072) -> Result<Value, String> {
1073    let entry = get_owned_discussion(state, discussion_id, client_id).await?;
1074    if entry.is_answering() {
1075        return Err(format!(
1076            "{discussion_id} is still answering — try again in a moment"
1077        ));
1078    }
1079    if entry.transcript_is_empty() {
1080        return Err(
1081            "this discussion has no turns yet — say what you are trying to do first".to_string(),
1082        );
1083    }
1084    let (intent, constraints) = distill(
1085        &entry.generator,
1086        &entry.distill_transcript(),
1087        &entry.repo_summary,
1088    )
1089    .await?;
1090    *lock(&entry.last_promote) = Some((intent.clone(), constraints.clone()));
1091    entry.touch();
1092    Ok(json!({
1093        "discussion_id": entry.id,
1094        "proposed_intent": intent,
1095        "constraints": constraints,
1096    }))
1097}
1098
1099/// The distillation call. Generation is injected exactly the way
1100/// `derive_app_contract` injects it into `derive_contract`, so the prompt +
1101/// parse + bounded-retry shape is testable with a scripted model.
1102async fn distill(
1103    generator: &Arc<dyn TurnGenerator>,
1104    transcript: &str,
1105    repo_summary: &str,
1106) -> Result<(String, Vec<String>), String> {
1107    let mut last_err = String::from("no attempt was made");
1108    for _ in 0..PROMOTE_MAX_ATTEMPTS {
1109        let prompt = format!(
1110            "A developer has been discussing a change to a codebase. Distill the discussion \
1111             into ONE actionable coding intent plus the constraints they agreed on.\n\n\
1112             REPOSITORY\n{repo_summary}\n\n\
1113             DISCUSSION (most recent turns)\n{transcript}\n\n\
1114             Return ONLY a JSON object, no prose and no code fences:\n\
1115             {{\n  \"proposed_intent\": \"one paragraph, imperative, what to change and why\",\n  \
1116             \"constraints\": [\"a thing the change must not break or must respect\"]\n}}\n\n\
1117             Rules:\n\
1118             - `proposed_intent` is an INSTRUCTION, not a summary of the conversation. Never \
1119             quote the transcript back.\n\
1120             - Include only constraints actually agreed in the discussion. If none were, \
1121             return an empty array — do not invent any.\n"
1122        );
1123        let text = match generator
1124            .generate(car_inference::GenerateRequest {
1125                prompt,
1126                params: car_inference::GenerateParams {
1127                    temperature: 0.0,
1128                    max_tokens: 1024,
1129                    thinking: car_inference::tasks::generate::ThinkingMode::Off,
1130                    ..Default::default()
1131                },
1132                ..Default::default()
1133            })
1134            .await
1135        {
1136            Ok(r) => r.text,
1137            Err(e) => {
1138                last_err = format!("generation failed: {e}");
1139                continue;
1140            }
1141        };
1142        let value = match super::contract::extract_json_object(&text) {
1143            Ok(v) => v,
1144            Err(e) => {
1145                last_err = format!("output did not parse: {e}");
1146                continue;
1147            }
1148        };
1149        let intent = value
1150            .get("proposed_intent")
1151            .and_then(Value::as_str)
1152            .unwrap_or_default()
1153            .trim()
1154            .to_string();
1155        if intent.is_empty() {
1156            last_err = "the model returned no proposed_intent".to_string();
1157            continue;
1158        }
1159        let constraints: Vec<String> = value
1160            .get("constraints")
1161            .and_then(Value::as_array)
1162            .map(|a| {
1163                a.iter()
1164                    .filter_map(Value::as_str)
1165                    .map(str::trim)
1166                    .filter(|s| !s.is_empty())
1167                    .map(str::to_string)
1168                    .collect()
1169            })
1170            .unwrap_or_default();
1171        return Ok((intent, constraints));
1172    }
1173    Err(format!(
1174        "could not distill this discussion into an intent after {PROMOTE_MAX_ATTEMPTS} \
1175         attempts: {last_err}"
1176    ))
1177}
1178
1179/// Constraints to fold into `derive_contract` for a `coder.start
1180/// { discussion_id }`.
1181///
1182/// An unknown id is a hard error — a run that silently drops its grounding is
1183/// worse than one that refuses to start. A distillation *failure* is not: the
1184/// operator already supplied the intent, so the run proceeds with no extra
1185/// constraints rather than being blocked by a model hiccup.
1186///
1187/// Refused while a turn is streaming, for the same reason `promote` is: the
1188/// distillation would run on the operator's question with no answer beside it,
1189/// and these constraints go straight into contract derivation.
1190pub async fn constraints_for_start(
1191    state: &Arc<ServerState>,
1192    discussion_id: &str,
1193) -> Result<Vec<String>, String> {
1194    let entry = get_discussion(state, discussion_id).await?;
1195    let cached = entry.constraints();
1196    if !cached.is_empty() {
1197        return Ok(cached);
1198    }
1199    if entry.is_answering() {
1200        return Err(format!(
1201            "{discussion_id} is still answering — wait for `turn_complete` before starting a \
1202             run from it, or the constraints would be distilled from a question with no \
1203             answer beside it"
1204        ));
1205    }
1206    if lock(&entry.last_promote).is_some() {
1207        // Promoted already, and it genuinely agreed no constraints.
1208        return Ok(Vec::new());
1209    }
1210    if entry.transcript_is_empty() {
1211        return Ok(Vec::new());
1212    }
1213    match distill(
1214        &entry.generator,
1215        &entry.distill_transcript(),
1216        &entry.repo_summary,
1217    )
1218    .await
1219    {
1220        Ok((intent, constraints)) => {
1221            *lock(&entry.last_promote) = Some((intent, constraints.clone()));
1222            Ok(constraints)
1223        }
1224        Err(e) => {
1225            tracing::warn!(discussion_id, "discussion constraints unavailable: {e}");
1226            Ok(Vec::new())
1227        }
1228    }
1229}
1230
1231/// Close a discussion: cancel any in-flight turn, free its runtime, end its
1232/// drain.
1233pub async fn close(
1234    state: &Arc<ServerState>,
1235    discussion_id: &str,
1236    client_id: &str,
1237) -> Result<Value, String> {
1238    // Ownership first, and against the live registry: a foreign `close` must
1239    // not be able to cancel a turn its owner is watching.
1240    get_owned_discussion(state, discussion_id, client_id).await?;
1241    let entry = state.coder_discussions.lock().await.remove(discussion_id);
1242    let Some(entry) = entry else {
1243        return Err(format!("no open discussion '{discussion_id}'"));
1244    };
1245    // Actually stop the model: without this the turn keeps running against a
1246    // live provider, billing tokens to a conversation nobody can read. This
1247    // also latches the discussion closed, so a `send` parked mid-dispatch never
1248    // spawns its turn behind us.
1249    entry.cancel_turn();
1250    Ok(json!({ "ok": true }))
1251}
1252
1253/// Drop a disconnecting client's discussion state (called from
1254/// `remove_session`).
1255///
1256/// A discussion is owned by the connection that opened it (module docs), so
1257/// this closes it outright rather than only unsubscribing — otherwise every
1258/// closed board leaks an `AssistantService`, a `Runtime`, an open runtime
1259/// session, and an unbounded transcript for the daemon's lifetime. Other
1260/// clients' subscriptions to a surviving discussion are just detached.
1261pub async fn drop_subscriptions_for_client(state: &ServerState, client_id: &str) {
1262    let (owned, others): (Vec<_>, Vec<_>) = {
1263        let open = state.coder_discussions.lock().await;
1264        open.values()
1265            .cloned()
1266            .partition(|e| e.owner_client_id == client_id)
1267    };
1268    for entry in &others {
1269        let _ = entry.cmds.send(StreamCmd::Detach(client_id.to_string()));
1270    }
1271    if owned.is_empty() {
1272        return;
1273    }
1274    let mut open = state.coder_discussions.lock().await;
1275    for entry in owned {
1276        entry.cancel_turn();
1277        open.remove(&entry.id);
1278    }
1279}
1280
1281// ---------------------------------------------------------------------------
1282// JSON-RPC handlers (thin parsing wrappers)
1283// ---------------------------------------------------------------------------
1284
1285#[derive(Deserialize)]
1286struct StartParams {
1287    repo: PathBuf,
1288}
1289
1290pub async fn handle_discuss_start(
1291    req: &JsonRpcMessage,
1292    state: &Arc<ServerState>,
1293    session: &Arc<ClientSession>,
1294) -> Result<Value, String> {
1295    let params: StartParams =
1296        serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
1297    let engine = crate::handler::get_inference_engine(state).clone();
1298    let generator: Arc<dyn TurnGenerator> = engine.clone();
1299    start_discussion(state, &params.repo, &session.client_id, engine, generator).await
1300}
1301
1302#[derive(Deserialize)]
1303struct SendParams {
1304    discussion_id: String,
1305    text: String,
1306}
1307
1308pub async fn handle_discuss_send(
1309    req: &JsonRpcMessage,
1310    state: &Arc<ServerState>,
1311    session: &Arc<ClientSession>,
1312) -> Result<Value, String> {
1313    let params: SendParams =
1314        serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
1315    send_message(
1316        state,
1317        &params.discussion_id,
1318        &session.client_id,
1319        &params.text,
1320    )
1321    .await
1322}
1323
1324#[derive(Deserialize)]
1325struct DiscussionIdParams {
1326    discussion_id: String,
1327}
1328
1329#[derive(Deserialize)]
1330struct SubscribeParams {
1331    discussion_id: String,
1332    #[serde(default)]
1333    from_seq: u64,
1334}
1335
1336pub async fn handle_discuss_subscribe(
1337    req: &JsonRpcMessage,
1338    state: &Arc<ServerState>,
1339    session: &Arc<ClientSession>,
1340) -> Result<Value, String> {
1341    let params: SubscribeParams =
1342        serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
1343    let entry = get_owned_discussion(state, &params.discussion_id, &session.client_id).await?;
1344    // A read counts as activity: a discussion an operator is actively watching
1345    // must not be eligible for the idle reaper.
1346    entry.touch();
1347    // Replay + register happen inside the drain task, which is the only owner
1348    // — so they are ordered against live emits without holding a lock across
1349    // any send.
1350    let (tx, rx) = oneshot::channel();
1351    entry
1352        .cmds
1353        .send(StreamCmd::Attach {
1354            client_id: session.client_id.clone(),
1355            channel: session.channel.clone(),
1356            from_seq: params.from_seq,
1357            replayed: tx,
1358        })
1359        .map_err(|_| format!("discussion '{}' is closing", params.discussion_id))?;
1360    let replayed = rx.await.unwrap_or(0);
1361    Ok(json!({ "events_replayed": replayed }))
1362}
1363
1364pub async fn handle_discuss_unsubscribe(
1365    req: &JsonRpcMessage,
1366    state: &Arc<ServerState>,
1367    session: &Arc<ClientSession>,
1368) -> Result<Value, String> {
1369    let params: DiscussionIdParams =
1370        serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
1371    if let Ok(entry) = get_discussion(state, &params.discussion_id).await {
1372        let _ = entry
1373            .cmds
1374            .send(StreamCmd::Detach(session.client_id.clone()));
1375    }
1376    Ok(json!({ "ok": true }))
1377}
1378
1379pub async fn handle_discuss_promote(
1380    req: &JsonRpcMessage,
1381    state: &Arc<ServerState>,
1382    session: &Arc<ClientSession>,
1383) -> Result<Value, String> {
1384    let params: DiscussionIdParams =
1385        serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
1386    promote(state, &params.discussion_id, &session.client_id).await
1387}
1388
1389pub async fn handle_discuss_close(
1390    req: &JsonRpcMessage,
1391    state: &Arc<ServerState>,
1392    session: &Arc<ClientSession>,
1393) -> Result<Value, String> {
1394    let params: DiscussionIdParams =
1395        serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
1396    close(state, &params.discussion_id, &session.client_id).await
1397}
1398
1399/// `coder.discuss.list` — this connection's open discussions.
1400///
1401/// Scoped to the caller, like every other `coder.discuss.*` method: a
1402/// discussion is owned by the connection that opened it, and listing another
1403/// connection's discussions would hand out ids the caller cannot use anyway.
1404pub async fn handle_discuss_list(
1405    state: &Arc<ServerState>,
1406    session: &Arc<ClientSession>,
1407) -> Result<Value, String> {
1408    let mut rows: Vec<Value> = state
1409        .coder_discussions
1410        .lock()
1411        .await
1412        .values()
1413        .filter(|e| e.owner_client_id == session.client_id)
1414        .map(|e| e.summary_row())
1415        .collect();
1416    rows.sort_by_key(|v| std::cmp::Reverse(v["created_at"].as_u64().unwrap_or(0)));
1417    Ok(json!({ "discussions": rows }))
1418}
1419
1420#[cfg(test)]
1421mod tests {
1422    use super::*;
1423    use async_trait::async_trait;
1424    use car_inference::{GenerateRequest, InferenceResult};
1425    use std::sync::atomic::AtomicUsize;
1426
1427    fn turn(text: &str, tool_calls: Value) -> InferenceResult {
1428        serde_json::from_value(json!({
1429            "text": text, "tool_calls": tool_calls,
1430            "trace_id": "t", "model_used": "scripted", "latency_ms": 0,
1431        }))
1432        .expect("scripted InferenceResult shape")
1433    }
1434
1435    struct Script {
1436        turns: Vec<InferenceResult>,
1437        cursor: AtomicUsize,
1438    }
1439
1440    #[async_trait]
1441    impl TurnGenerator for Script {
1442        async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
1443            let i = self.cursor.fetch_add(1, Ordering::SeqCst);
1444            self.turns
1445                .get(i)
1446                .cloned()
1447                .ok_or_else(|| "script exhausted".to_string())
1448        }
1449    }
1450
1451    /// A generator that blocks until released — lets a test observe a turn
1452    /// while it is genuinely in flight.
1453    ///
1454    /// Released with `notify_one`, never `notify_waiters`: the turn is spawned,
1455    /// so the test can reach the release before the task has registered as a
1456    /// waiter, and `notify_waiters` wakes only waiters that already exist.
1457    /// `notify_one` stores a permit, so the ordering does not matter.
1458    struct Blocking {
1459        gate: Arc<tokio::sync::Notify>,
1460    }
1461
1462    #[async_trait]
1463    impl TurnGenerator for Blocking {
1464        async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
1465            self.gate.notified().await;
1466            Ok(turn("done at last", json!([])))
1467        }
1468    }
1469
1470    /// Counts invocations — for asserting a turn NEVER reached the model.
1471    struct Counting {
1472        calls: Arc<AtomicUsize>,
1473    }
1474
1475    #[async_trait]
1476    impl TurnGenerator for Counting {
1477        async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
1478            self.calls.fetch_add(1, Ordering::SeqCst);
1479            Ok(turn("counted", json!([])))
1480        }
1481    }
1482
1483    fn init_repo(dir: &Path) {
1484        for args in [
1485            vec!["init", "-q", "-b", "main"],
1486            vec![
1487                "-c",
1488                "user.name=t",
1489                "-c",
1490                "user.email=t@t",
1491                "commit",
1492                "-q",
1493                "--allow-empty",
1494                "-m",
1495                "init",
1496            ],
1497        ] {
1498            let out = std::process::Command::new("git")
1499                .arg("-C")
1500                .arg(dir)
1501                .args(&args)
1502                .output()
1503                .unwrap();
1504            assert!(
1505                out.status.success(),
1506                "{}",
1507                String::from_utf8_lossy(&out.stderr)
1508            );
1509        }
1510    }
1511
1512    fn engine(root: &Path) -> Arc<car_inference::InferenceEngine> {
1513        let mut cfg = car_inference::InferenceConfig::default();
1514        cfg.models_dir = root.join("models");
1515        Arc::new(car_inference::InferenceEngine::new(cfg))
1516    }
1517
1518    /// A standalone daemon state plus the journal dir it writes to — the
1519    /// caller keeps the `TempDir` alive for the length of the test.
1520    fn state() -> (Arc<ServerState>, tempfile::TempDir) {
1521        let journal = tempfile::tempdir().unwrap();
1522        let state = Arc::new(ServerState::standalone(journal.path().to_path_buf()));
1523        (state, journal)
1524    }
1525
1526    async fn start(
1527        state: &Arc<ServerState>,
1528        repo: &Path,
1529        generator: Arc<dyn TurnGenerator>,
1530    ) -> String {
1531        let started = start_discussion(state, repo, "owner-1", engine(repo), generator)
1532            .await
1533            .unwrap();
1534        started["discussion_id"].as_str().unwrap().to_string()
1535    }
1536
1537    /// A `ClientSession` over a drain sink — enough for the handlers that need
1538    /// a connection identity, without a tungstenite handshake.
1539    async fn client(state: &Arc<ServerState>, id: &str) -> Arc<ClientSession> {
1540        state
1541            .create_session(id, Arc::new(crate::session::WsChannel::test_stub()))
1542            .await
1543            .unwrap()
1544    }
1545
1546    /// A WS sink that keeps every frame instead of writing it, so a test can
1547    /// read exactly what a subscriber's lane delivered. `test_stub` drains to
1548    /// nowhere, which is enough for membership checks but says nothing about
1549    /// what arrived.
1550    struct CaptureSink(Arc<StdMutex<Vec<String>>>);
1551
1552    impl futures::Sink<tokio_tungstenite::tungstenite::Message> for CaptureSink {
1553        type Error = tokio_tungstenite::tungstenite::Error;
1554
1555        fn poll_ready(
1556            self: std::pin::Pin<&mut Self>,
1557            _: &mut std::task::Context<'_>,
1558        ) -> std::task::Poll<Result<(), Self::Error>> {
1559            std::task::Poll::Ready(Ok(()))
1560        }
1561
1562        fn start_send(
1563            self: std::pin::Pin<&mut Self>,
1564            item: tokio_tungstenite::tungstenite::Message,
1565        ) -> Result<(), Self::Error> {
1566            if let tokio_tungstenite::tungstenite::Message::Text(text) = item {
1567                lock(&self.0).push(text.to_string());
1568            }
1569            Ok(())
1570        }
1571
1572        fn poll_flush(
1573            self: std::pin::Pin<&mut Self>,
1574            _: &mut std::task::Context<'_>,
1575        ) -> std::task::Poll<Result<(), Self::Error>> {
1576            std::task::Poll::Ready(Ok(()))
1577        }
1578
1579        fn poll_close(
1580            self: std::pin::Pin<&mut Self>,
1581            _: &mut std::task::Context<'_>,
1582        ) -> std::task::Poll<Result<(), Self::Error>> {
1583            std::task::Poll::Ready(Ok(()))
1584        }
1585    }
1586
1587    /// A real `WsChannel` over [`CaptureSink`], plus the frames it collected.
1588    /// Locking its `write` half is a half-open peer: writes stop completing and
1589    /// never fail, exactly what wedges a subscriber's lane.
1590    fn capturing_channel() -> (Arc<WsChannel>, Arc<StdMutex<Vec<String>>>) {
1591        let frames = Arc::new(StdMutex::new(Vec::new()));
1592        let sink: crate::session::WsSink = Box::pin(CaptureSink(frames.clone()));
1593        let channel = Arc::new(WsChannel {
1594            write: tokio::sync::Mutex::new(sink),
1595            pending: tokio::sync::Mutex::new(HashMap::new()),
1596            active_actions: tokio::sync::Mutex::new(HashMap::new()),
1597            next_id: AtomicU64::new(0),
1598        });
1599        (channel, frames)
1600    }
1601
1602    fn rpc_req(params: Value) -> JsonRpcMessage {
1603        serde_json::from_value(json!({ "jsonrpc": "2.0", "id": 1, "params": params }))
1604            .expect("JsonRpcMessage shape")
1605    }
1606
1607    /// The `seq` of every `coder.discuss.event` frame a lane delivered.
1608    fn delivered_seqs(frames: &Arc<StdMutex<Vec<String>>>) -> Vec<u64> {
1609        lock(frames)
1610            .iter()
1611            .map(|f| serde_json::from_str::<Value>(f).expect("a lane frame must be JSON"))
1612            .inspect(|v| assert_eq!(v["method"], "coder.discuss.event", "unexpected frame: {v}"))
1613            .map(|v| {
1614                v["params"]["seq"]
1615                    .as_u64()
1616                    .expect("every event carries a seq")
1617            })
1618            .collect()
1619    }
1620
1621    async fn wait_for_turn_complete(entry: &Arc<DiscussionEntry>) {
1622        for _ in 0..400 {
1623            {
1624                let events = entry.events.lock().await;
1625                if events
1626                    .iter()
1627                    .any(|e| matches!(e.kind, DiscussEventKind::TurnComplete {}))
1628                {
1629                    return;
1630                }
1631            }
1632            tokio::time::sleep(std::time::Duration::from_millis(25)).await;
1633        }
1634        panic!("discussion turn never completed");
1635    }
1636
1637    #[tokio::test]
1638    async fn discuss_start_rejects_a_non_git_directory() {
1639        let dir = tempfile::tempdir().unwrap();
1640        let (state, _journal) = state();
1641        let script: Arc<dyn TurnGenerator> = Arc::new(Script {
1642            turns: vec![],
1643            cursor: AtomicUsize::new(0),
1644        });
1645        let err = start_discussion(&state, dir.path(), "owner-1", engine(dir.path()), script)
1646            .await
1647            .unwrap_err();
1648        assert!(
1649            err.contains("is not a git repository")
1650                && err.contains("discuss needs a repo to ground itself in"),
1651            "operator-readable non-repo error, got: {err}"
1652        );
1653    }
1654
1655    /// The load-bearing property: a discussion NEVER writes in the target repo.
1656    #[tokio::test]
1657    async fn a_discussion_writes_nothing_in_the_repo() {
1658        let repo = tempfile::tempdir().unwrap();
1659        init_repo(repo.path());
1660        std::fs::write(repo.path().join("keep.txt"), "original").unwrap();
1661        let (state, _journal) = state();
1662
1663        let script: Arc<dyn TurnGenerator> = Arc::new(Script {
1664            turns: vec![
1665                turn(
1666                    "",
1667                    json!([{
1668                        "id": "c1", "name": "write_file",
1669                        "arguments": {"path": "sneaky.txt", "content": "written by a discussion"}
1670                    }]),
1671                ),
1672                turn(
1673                    "",
1674                    json!([{
1675                        "id": "c2", "name": "shell",
1676                        "arguments": {"command": "printf x > shelled.txt"}
1677                    }]),
1678                ),
1679                turn(
1680                    "I cannot edit from a discussion; here is what I would change.",
1681                    json!([]),
1682                ),
1683            ],
1684            cursor: AtomicUsize::new(0),
1685        });
1686
1687        let id = start(&state, repo.path(), script).await;
1688        assert!(id.starts_with("disc-"));
1689        send_message(
1690            &state,
1691            &id,
1692            "owner-1",
1693            "can you just make the change for me?",
1694        )
1695        .await
1696        .unwrap();
1697        let entry = get_discussion(&state, &id).await.unwrap();
1698        wait_for_turn_complete(&entry).await;
1699
1700        assert!(
1701            !repo.path().join("sneaky.txt").exists(),
1702            "a discussion must not create files in the repo"
1703        );
1704        assert!(
1705            !repo.path().join("shelled.txt").exists(),
1706            "a discussion must not run shell commands that write"
1707        );
1708        assert_eq!(
1709            std::fs::read_to_string(repo.path().join("keep.txt")).unwrap(),
1710            "original"
1711        );
1712
1713        let events = entry.events.lock().await;
1714        assert!(
1715            events.iter().any(|e| matches!(
1716                &e.kind,
1717                DiscussEventKind::ToolResult { ok, preview, .. }
1718                    if !ok && preview.contains("read-only")
1719            )),
1720            "the denial must surface as a tool_result"
1721        );
1722    }
1723
1724    /// The other half of the boundary: a discussion cannot READ outside its
1725    /// repo. Mutation-gating alone left the read tools pointed at the whole
1726    /// filesystem, and their output streams to every subscriber.
1727    #[tokio::test]
1728    async fn a_discussion_cannot_read_outside_the_repo() {
1729        let outside = tempfile::tempdir().unwrap();
1730        let secret_path = outside.path().join("credentials.txt");
1731        std::fs::write(&secret_path, "sk-ant-SUPERSECRETVALUE").unwrap();
1732
1733        let repo = tempfile::tempdir().unwrap();
1734        init_repo(repo.path());
1735        let (state, _journal) = state();
1736
1737        let script: Arc<dyn TurnGenerator> = Arc::new(Script {
1738            turns: vec![
1739                // Absolute path outside the repo — the exfiltration attempt.
1740                turn(
1741                    "",
1742                    json!([{
1743                        "id": "c1", "name": "read_file",
1744                        "arguments": {"path": secret_path.to_string_lossy()}
1745                    }]),
1746                ),
1747                // ...and the directory-scanning variant.
1748                turn(
1749                    "",
1750                    json!([{
1751                        "id": "c2", "name": "grep_files",
1752                        "arguments": {"path": outside.path().to_string_lossy(), "pattern": "sk-ant-"}
1753                    }]),
1754                ),
1755                turn("I can only read inside this repository.", json!([])),
1756            ],
1757            cursor: AtomicUsize::new(0),
1758        });
1759
1760        let id = start(&state, repo.path(), script).await;
1761        send_message(
1762            &state,
1763            &id,
1764            "owner-1",
1765            "what credentials does this project use?",
1766        )
1767        .await
1768        .unwrap();
1769        let entry = get_discussion(&state, &id).await.unwrap();
1770        wait_for_turn_complete(&entry).await;
1771
1772        let events = entry.events.lock().await;
1773        let stream = serde_json::to_string(&*events).unwrap();
1774        assert!(
1775            !stream.contains("SUPERSECRETVALUE"),
1776            "a discussion must never stream content from outside its repo: {stream}"
1777        );
1778    }
1779
1780    #[tokio::test]
1781    async fn promote_distills_an_intent_and_starts_nothing() {
1782        let repo = tempfile::tempdir().unwrap();
1783        init_repo(repo.path());
1784        let (state, _journal) = state();
1785
1786        let script: Arc<dyn TurnGenerator> = Arc::new(Script {
1787            turns: vec![
1788                turn("The Windows path is the risky one.", json!([])),
1789                turn(
1790                    r#"{"proposed_intent":"Make the config loader resolve paths on Windows.",
1791                        "constraints":["do not change the POSIX behavior"]}"#,
1792                    json!([]),
1793                ),
1794            ],
1795            cursor: AtomicUsize::new(0),
1796        });
1797
1798        let id = start(&state, repo.path(), script).await;
1799        send_message(
1800            &state,
1801            &id,
1802            "owner-1",
1803            "what is fragile about the config loader?",
1804        )
1805        .await
1806        .unwrap();
1807        let entry = get_discussion(&state, &id).await.unwrap();
1808        wait_for_turn_complete(&entry).await;
1809
1810        let promoted = promote(&state, &id, "owner-1").await.unwrap();
1811        assert_eq!(
1812            promoted["proposed_intent"],
1813            "Make the config loader resolve paths on Windows."
1814        );
1815        assert_eq!(
1816            promoted["constraints"],
1817            json!(["do not change the POSIX behavior"])
1818        );
1819        assert!(state.coder_sessions.lock().await.is_empty());
1820        assert_eq!(
1821            constraints_for_start(&state, &id).await.unwrap(),
1822            vec!["do not change the POSIX behavior".to_string()]
1823        );
1824    }
1825
1826    /// A second `send` while a turn is streaming is refused, not silently
1827    /// interleaved — and `promote` refuses too rather than distilling a
1828    /// question with no answer beside it.
1829    #[tokio::test]
1830    async fn a_turn_in_flight_blocks_a_second_send_and_promote() {
1831        let repo = tempfile::tempdir().unwrap();
1832        init_repo(repo.path());
1833        let (state, _journal) = state();
1834        let gate = Arc::new(tokio::sync::Notify::new());
1835        let generator: Arc<dyn TurnGenerator> = Arc::new(Blocking { gate: gate.clone() });
1836
1837        let id = start(&state, repo.path(), generator).await;
1838        send_message(&state, &id, "owner-1", "first question")
1839            .await
1840            .unwrap();
1841
1842        let entry = get_discussion(&state, &id).await.unwrap();
1843        for _ in 0..200 {
1844            if entry.is_answering() {
1845                break;
1846            }
1847            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1848        }
1849        assert!(entry.is_answering(), "the turn should be in flight");
1850
1851        let err = send_message(&state, &id, "owner-1", "second question")
1852            .await
1853            .unwrap_err();
1854        assert!(
1855            err.contains("still answering"),
1856            "a concurrent send must be refused, not silently lose a turn: {err}"
1857        );
1858        let err = promote(&state, &id, "owner-1").await.unwrap_err();
1859        assert!(
1860            err.contains("still answering"),
1861            "promote must not distill a half-finished turn: {err}"
1862        );
1863
1864        gate.notify_one();
1865        wait_for_turn_complete(&entry).await;
1866    }
1867
1868    /// Closing cancels the in-flight turn rather than leaving it billing tokens
1869    /// to a conversation nobody can read.
1870    #[tokio::test]
1871    async fn close_cancels_an_in_flight_turn() {
1872        let repo = tempfile::tempdir().unwrap();
1873        init_repo(repo.path());
1874        let (state, _journal) = state();
1875        let gate = Arc::new(tokio::sync::Notify::new());
1876        let generator: Arc<dyn TurnGenerator> = Arc::new(Blocking { gate });
1877
1878        let id = start(&state, repo.path(), generator).await;
1879        send_message(&state, &id, "owner-1", "a broad question")
1880            .await
1881            .unwrap();
1882        let entry = get_discussion(&state, &id).await.unwrap();
1883        for _ in 0..200 {
1884            if entry.is_answering() {
1885                break;
1886            }
1887            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1888        }
1889
1890        close(&state, &id, "owner-1").await.unwrap();
1891        assert!(!entry.is_answering(), "close must stop the turn");
1892        assert!(state.coder_discussions.lock().await.is_empty());
1893    }
1894
1895    /// A `send` whose handler future is dropped after the turn was dispatched
1896    /// must NOT leave the discussion latched as answering.
1897    ///
1898    /// `coder.discuss.send` is not deadline-exempt, so the daemon's handler
1899    /// deadline cancels this future at its one remaining await — the turn's
1900    /// first-event cursor. `in_flight` is set by CAS before that and cleared
1901    /// only at the tail of the spawned turn task, so the question is whether
1902    /// that task exists. It does: the dispatch is complete before this await is
1903    /// ever reached, so the drop costs the caller its `seq` reply and nothing
1904    /// else. The turn answers the message, releases `in_flight`, and the
1905    /// discussion is usable again — rather than answering "still answering the
1906    /// previous message" forever with nothing running (`reap_idle` runs only on
1907    /// the next `discuss.start`, so a quiet daemon never reclaimed that).
1908    #[tokio::test]
1909    async fn a_send_cancelled_after_dispatch_leaves_the_discussion_usable() {
1910        let repo = tempfile::tempdir().unwrap();
1911        init_repo(repo.path());
1912        let (state, _journal) = state();
1913        let script: Arc<dyn TurnGenerator> = Arc::new(Script {
1914            turns: vec![
1915                turn("answered anyway", json!([])),
1916                turn("answered on the retry", json!([])),
1917            ],
1918            cursor: AtomicUsize::new(0),
1919        });
1920        let id = start(&state, repo.path(), script).await;
1921        let entry = get_discussion(&state, &id).await.unwrap();
1922
1923        // Cancellation IS "the future is dropped at an .await point" — that is
1924        // all `tokio::time::timeout` does to a handler. Poll once to get past
1925        // the CAS and the dispatch, park on the turn's first-event cursor, then
1926        // drop it there.
1927        let mut send = Box::pin(send_message(
1928            &state,
1929            &id,
1930            "owner-1",
1931            "the message whose reply frame gets cancelled",
1932        ));
1933        assert!(
1934            matches!(futures::poll!(send.as_mut()), std::task::Poll::Pending),
1935            "the fixture needs the send parked on its cursor"
1936        );
1937        assert!(
1938            entry.is_answering(),
1939            "the fixture needs the CAS to have run"
1940        );
1941        drop(send);
1942
1943        // The turn was already dispatched, so it runs and releases the latch.
1944        wait_for_turn_complete(&entry).await;
1945        for _ in 0..200 {
1946            if !entry.is_answering() {
1947                break;
1948            }
1949            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1950        }
1951        assert!(
1952            !entry.is_answering(),
1953            "a cancelled handler must not strand `in_flight`"
1954        );
1955
1956        // ...and the discussion still works.
1957        send_message(&state, &id, "owner-1", "second try")
1958            .await
1959            .expect("the discussion must still accept a message");
1960    }
1961
1962    /// A `send` whose dispatch is refused by a `close` must never reach the
1963    /// model.
1964    ///
1965    /// `cancel_turn` used to read `turn_task` before `send_message` stored it —
1966    /// the store happened only after the first `emit().await` — so a close in
1967    /// that window found nothing to abort, removed the entry from the registry,
1968    /// and then `send_message` resumed and spawned a 12-turn model loop against
1969    /// a discussion nothing could reach. The turn slot latch is what closed
1970    /// that: `close` latches it, the dispatch checks it under the same lock,
1971    /// and a send that arrives after the latch is REFUSED. Here the latch is
1972    /// set without removing the registry entry, so the send reaches the
1973    /// dispatch and is refused exactly there.
1974    #[tokio::test]
1975    async fn a_close_racing_a_dispatching_send_never_starts_the_turn() {
1976        let repo = tempfile::tempdir().unwrap();
1977        init_repo(repo.path());
1978        let (state, _journal) = state();
1979        let calls = Arc::new(AtomicUsize::new(0));
1980        let script: Arc<dyn TurnGenerator> = Arc::new(Counting {
1981            calls: calls.clone(),
1982        });
1983        let id = start(&state, repo.path(), script).await;
1984        let entry = get_discussion(&state, &id).await.unwrap();
1985
1986        entry.cancel_turn();
1987
1988        let err = send_message(&state, &id, "owner-1", "a broad question")
1989            .await
1990            .unwrap_err();
1991        assert!(
1992            err.contains("closed while your message was being dispatched"),
1993            "the caller must be told the send did not run: {err}"
1994        );
1995        assert_eq!(
1996            calls.load(Ordering::SeqCst),
1997            0,
1998            "a closed discussion must never reach the model"
1999        );
2000        assert!(!entry.is_answering());
2001
2002        close(&state, &id, "owner-1").await.unwrap();
2003        assert!(state.coder_discussions.lock().await.is_empty());
2004    }
2005
2006    /// A discussion is owned by the connection that opened it — and that is now
2007    /// enforced, not merely recorded. Every method resolved by id alone, so any
2008    /// connected client could send into, promote, or close another's
2009    /// discussion; closing one mid-turn cancels a turn its owner is watching.
2010    #[tokio::test]
2011    async fn another_connection_cannot_drive_a_discussion() {
2012        let repo = tempfile::tempdir().unwrap();
2013        init_repo(repo.path());
2014        let (state, _journal) = state();
2015        let script: Arc<dyn TurnGenerator> = Arc::new(Script {
2016            turns: vec![],
2017            cursor: AtomicUsize::new(0),
2018        });
2019        let id = start(&state, repo.path(), script).await;
2020
2021        for err in [
2022            send_message(&state, &id, "intruder", "run this for me")
2023                .await
2024                .unwrap_err(),
2025            promote(&state, &id, "intruder").await.unwrap_err(),
2026            close(&state, &id, "intruder").await.unwrap_err(),
2027            // The path `coder.discuss.subscribe` and `coder.start
2028            // { discussion_id }` both resolve through.
2029            match get_owned_discussion(&state, &id, "intruder").await {
2030                Ok(_) => panic!("a foreign client must not resolve another's discussion"),
2031                Err(e) => e,
2032            },
2033        ] {
2034            assert!(
2035                err.contains("belongs to another connection"),
2036                "a foreign client must be refused: {err}"
2037            );
2038        }
2039
2040        // Untouched, and still the owner's to close.
2041        assert_eq!(state.coder_discussions.lock().await.len(), 1);
2042        close(&state, &id, "owner-1").await.unwrap();
2043    }
2044
2045    /// Operator text is retained in the transcript, the replay buffer and the
2046    /// distill prompt, so it needs the byte cap `summarize_repo` already has.
2047    #[tokio::test]
2048    async fn an_oversized_message_is_refused() {
2049        let repo = tempfile::tempdir().unwrap();
2050        init_repo(repo.path());
2051        let (state, _journal) = state();
2052        let script: Arc<dyn TurnGenerator> = Arc::new(Script {
2053            turns: vec![],
2054            cursor: AtomicUsize::new(0),
2055        });
2056        let id = start(&state, repo.path(), script).await;
2057        let entry = get_discussion(&state, &id).await.unwrap();
2058
2059        let err = send_message(
2060            &state,
2061            &id,
2062            "owner-1",
2063            &"x".repeat(DISCUSS_MESSAGE_MAX_BYTES + 1),
2064        )
2065        .await
2066        .unwrap_err();
2067        assert!(err.contains("the limit is"), "{err}");
2068        // Refused BEFORE the latch, so the discussion is still usable.
2069        assert!(!entry.is_answering());
2070        assert!(entry.transcript_is_empty());
2071    }
2072
2073    /// A disconnecting client's discussions are freed, not leaked for the
2074    /// daemon's lifetime.
2075    #[tokio::test]
2076    async fn disconnect_closes_the_owning_clients_discussions() {
2077        let repo = tempfile::tempdir().unwrap();
2078        init_repo(repo.path());
2079        let (state, _journal) = state();
2080        let script: Arc<dyn TurnGenerator> = Arc::new(Script {
2081            turns: vec![],
2082            cursor: AtomicUsize::new(0),
2083        });
2084        let id = start(&state, repo.path(), script).await;
2085        assert_eq!(state.coder_discussions.lock().await.len(), 1);
2086
2087        // A different client disconnecting leaves it alone...
2088        drop_subscriptions_for_client(&state, "someone-else").await;
2089        assert_eq!(state.coder_discussions.lock().await.len(), 1);
2090
2091        // ...its owner disconnecting closes it.
2092        drop_subscriptions_for_client(&state, "owner-1").await;
2093        assert!(state.coder_discussions.lock().await.is_empty());
2094        assert!(get_discussion(&state, &id).await.is_err());
2095    }
2096
2097    /// The cap is a slot RESERVATION, so the test holds the slots directly
2098    /// rather than building eight full assistant runtimes — each
2099    /// `start_discussion` binds a substrate and registers ~40 tools, and doing
2100    /// that eight times to assert a length check cost minutes of CI for
2101    /// nothing.
2102    #[tokio::test]
2103    async fn open_discussions_are_capped() {
2104        let repo = tempfile::tempdir().unwrap();
2105        init_repo(repo.path());
2106        let (state, _journal) = state();
2107        let held: Vec<_> = (0..MAX_OPEN_DISCUSSIONS)
2108            .map(|_| {
2109                state
2110                    .coder_discussion_slots
2111                    .clone()
2112                    .try_acquire_owned()
2113                    .expect("a fresh daemon has every slot free")
2114            })
2115            .collect();
2116
2117        let script: Arc<dyn TurnGenerator> = Arc::new(Script {
2118            turns: vec![],
2119            cursor: AtomicUsize::new(0),
2120        });
2121        let err = start_discussion(&state, repo.path(), "owner-1", engine(repo.path()), script)
2122            .await
2123            .unwrap_err();
2124        assert!(err.contains("already open"), "{err}");
2125
2126        // ...and a freed slot admits the next one.
2127        drop(held);
2128        let script: Arc<dyn TurnGenerator> = Arc::new(Script {
2129            turns: vec![],
2130            cursor: AtomicUsize::new(0),
2131        });
2132        start_discussion(&state, repo.path(), "owner-1", engine(repo.path()), script)
2133            .await
2134            .expect("a released slot must be reusable");
2135    }
2136
2137    /// The cap must hold under CONCURRENT starts, which is what it did not do:
2138    /// the count was read, the registry lock released, and two awaits (bind the
2139    /// substrate, build the runtime) ran before the insert — and the daemon
2140    /// runs a connection's requests concurrently, so N pipelined starts all
2141    /// read `len() == 0`, all passed a cap of 8, and all built a runtime.
2142    ///
2143    /// One slot is left free and four starts race for it: exactly one may win,
2144    /// and the three losers must fail BEFORE building anything.
2145    #[tokio::test]
2146    async fn concurrent_starts_cannot_exceed_the_open_discussion_cap() {
2147        let repo = tempfile::tempdir().unwrap();
2148        init_repo(repo.path());
2149        let (state, _journal) = state();
2150        let _held: Vec<_> = (0..MAX_OPEN_DISCUSSIONS - 1)
2151            .map(|_| {
2152                state
2153                    .coder_discussion_slots
2154                    .clone()
2155                    .try_acquire_owned()
2156                    .unwrap()
2157            })
2158            .collect();
2159
2160        let mut racers = Vec::new();
2161        for _ in 0..4 {
2162            let state = state.clone();
2163            let repo = repo.path().to_path_buf();
2164            racers.push(tokio::spawn(async move {
2165                let script: Arc<dyn TurnGenerator> = Arc::new(Script {
2166                    turns: vec![],
2167                    cursor: AtomicUsize::new(0),
2168                });
2169                start_discussion(&state, &repo, "owner-1", engine(&repo), script).await
2170            }));
2171        }
2172
2173        let mut admitted = 0;
2174        let mut refused = 0;
2175        for racer in racers {
2176            match racer.await.unwrap() {
2177                Ok(_) => admitted += 1,
2178                Err(e) => {
2179                    assert!(e.contains("already open"), "unexpected refusal: {e}");
2180                    refused += 1;
2181                }
2182            }
2183        }
2184        assert_eq!(admitted, 1, "exactly one racer may take the last slot");
2185        assert_eq!(refused, 3);
2186        assert_eq!(
2187            state.coder_discussions.lock().await.len(),
2188            1,
2189            "the registry must never exceed the cap"
2190        );
2191    }
2192
2193    #[tokio::test]
2194    async fn unknown_discussion_ids_are_clear_errors() {
2195        let (state, _journal) = state();
2196        for err in [
2197            send_message(&state, "disc-nope", "owner-1", "hi")
2198                .await
2199                .unwrap_err(),
2200            promote(&state, "disc-nope", "owner-1").await.unwrap_err(),
2201            constraints_for_start(&state, "disc-nope")
2202                .await
2203                .unwrap_err(),
2204        ] {
2205            assert!(err.contains("disc-nope"), "must name the id, got: {err}");
2206        }
2207        assert!(close(&state, "disc-nope", "owner-1").await.is_err());
2208    }
2209
2210    #[tokio::test]
2211    async fn list_and_close_track_open_discussions() {
2212        let repo = tempfile::tempdir().unwrap();
2213        init_repo(repo.path());
2214        let (state, _journal) = state();
2215        let script: Arc<dyn TurnGenerator> = Arc::new(Script {
2216            turns: vec![],
2217            cursor: AtomicUsize::new(0),
2218        });
2219        let id = start(&state, repo.path(), script).await;
2220        let owner = client(&state, "owner-1").await;
2221
2222        let listed = handle_discuss_list(&state, &owner).await.unwrap();
2223        assert_eq!(listed["discussions"].as_array().unwrap().len(), 1);
2224        assert_eq!(listed["discussions"][0]["discussion_id"], id.as_str());
2225        assert_eq!(listed["discussions"][0]["turns"], 0);
2226
2227        // ...and it is scoped to the owning connection.
2228        let stranger = client(&state, "someone-else").await;
2229        let listed = handle_discuss_list(&state, &stranger).await.unwrap();
2230        assert!(
2231            listed["discussions"].as_array().unwrap().is_empty(),
2232            "another connection must not see this discussion: {listed}"
2233        );
2234
2235        assert_eq!(
2236            close(&state, &id, "owner-1").await.unwrap(),
2237            json!({ "ok": true })
2238        );
2239        let listed = handle_discuss_list(&state, &owner).await.unwrap();
2240        assert!(listed["discussions"].as_array().unwrap().is_empty());
2241    }
2242
2243    /// The stated guarantee, measured where a client actually lives: what a
2244    /// SUBSCRIBER receives across an attach is contiguous from its cursor —
2245    /// no gap, no duplicate — even when emits are racing the attach.
2246    ///
2247    /// Asserting on the buffer proves only that the drain is the single writer.
2248    /// The property clients depend on spans three more hops the buffer never
2249    /// touches: the replay clone at attach, the per-subscriber queue, and that
2250    /// lane's sender task. An attach that registered before replaying would
2251    /// duplicate here and an attach that replayed before registering would drop
2252    /// whatever emitted in between, and the buffer would look perfect either
2253    /// way.
2254    ///
2255    /// **The replay hop has to actually run.** `handle_discuss_subscribe` has
2256    /// exactly one await before it enqueues `Attach`, and it resolves on the
2257    /// first poll; on the current-thread test runtime the "racing" emitter had
2258    /// therefore never been polled when the attach landed, so
2259    /// `events_replayed` was 0 on every run and `replayed <= 30` was satisfied
2260    /// by nothing having been replayed at all. Mutating the replay filter to
2261    /// `e.seq > from_seq` — the off-by-one that drops the first event of every
2262    /// real client resume — left the test green. So: yield until the emitter
2263    /// has genuinely produced events, assert the replay is non-empty, and
2264    /// attach a second time from a NON-ZERO cursor, where an off-by-one is a
2265    /// wrong first seq rather than a merely smaller count.
2266    #[tokio::test]
2267    async fn a_subscriber_receives_every_seq_exactly_once_across_its_attach() {
2268        let repo = tempfile::tempdir().unwrap();
2269        init_repo(repo.path());
2270        let (state, _journal) = state();
2271        let script: Arc<dyn TurnGenerator> = Arc::new(Script {
2272            turns: vec![],
2273            cursor: AtomicUsize::new(0),
2274        });
2275        let id = start(&state, repo.path(), script).await;
2276        let entry = get_discussion(&state, &id).await.unwrap();
2277
2278        let (channel, frames) = capturing_channel();
2279        let owner = state
2280            .create_session("owner-1", channel.clone())
2281            .await
2282            .unwrap();
2283
2284        // Emitted WHILE the attach is in flight: each of these lands on one
2285        // side or the other of the `Attach` command, and the subscriber must
2286        // see it exactly once either way.
2287        let racing = {
2288            let entry = entry.clone();
2289            tokio::spawn(async move {
2290                for i in 0..30u64 {
2291                    entry
2292                        .emit(DiscussEventKind::AssistantDelta {
2293                            text: format!("during-{i}"),
2294                        })
2295                        .await;
2296                }
2297            })
2298        };
2299        // Let the emitter actually get ahead of the attach. Without this the
2300        // attach wins every poll and there is no race to observe.
2301        while entry.events.lock().await.is_empty() {
2302            tokio::task::yield_now().await;
2303        }
2304        let subscribed = handle_discuss_subscribe(
2305            &rpc_req(json!({ "discussion_id": id, "from_seq": 0 })),
2306            &state,
2307            &owner,
2308        )
2309        .await
2310        .unwrap();
2311        racing.await.unwrap();
2312
2313        // ...and after it, live through the same lane.
2314        for i in 0..20u64 {
2315            entry
2316                .emit(DiscussEventKind::AssistantDelta {
2317                    text: format!("after-{i}"),
2318                })
2319                .await;
2320        }
2321
2322        const TOTAL: usize = 50;
2323        let replayed = subscribed["events_replayed"].as_u64().unwrap();
2324        assert!(
2325            replayed > 0,
2326            "the attach replayed nothing, so this test never exercised the \
2327             replay hop it exists to cover"
2328        );
2329        assert!(
2330            replayed <= 30,
2331            "replay cannot exceed what was emitted before the attach: {replayed}"
2332        );
2333
2334        let mut seqs = Vec::new();
2335        for _ in 0..400 {
2336            seqs = delivered_seqs(&frames);
2337            if seqs.len() >= TOTAL {
2338                break;
2339            }
2340            tokio::time::sleep(std::time::Duration::from_millis(25)).await;
2341        }
2342        assert_eq!(
2343            seqs,
2344            (0..TOTAL as u64).collect::<Vec<_>>(),
2345            "a subscriber must receive seq 0..{TOTAL} once each, in order"
2346        );
2347
2348        // ...and a resume from a non-zero cursor is inclusive of that cursor.
2349        // Every seq is in the buffer now, so this is exact: an off-by-one in
2350        // the replay filter shows up as a missing FIRST event, not as a count
2351        // that merely looks plausible.
2352        const RESUME_FROM: u64 = 17;
2353        let (resumed_channel, resumed_frames) = capturing_channel();
2354        // Same client id: a discussion is owned by the connection that opened
2355        // it, and re-attaching replaces that connection's lane.
2356        let resumed = state
2357            .create_session("owner-1", resumed_channel)
2358            .await
2359            .unwrap();
2360        let reattached = handle_discuss_subscribe(
2361            &rpc_req(json!({ "discussion_id": id, "from_seq": RESUME_FROM })),
2362            &state,
2363            &resumed,
2364        )
2365        .await
2366        .unwrap();
2367        assert_eq!(
2368            reattached["events_replayed"].as_u64().unwrap(),
2369            TOTAL as u64 - RESUME_FROM,
2370            "a resume from {RESUME_FROM} must replay seq {RESUME_FROM}..{TOTAL}"
2371        );
2372
2373        let mut resumed_seqs = Vec::new();
2374        for _ in 0..400 {
2375            resumed_seqs = delivered_seqs(&resumed_frames);
2376            if resumed_seqs.len() >= TOTAL - RESUME_FROM as usize {
2377                break;
2378            }
2379            tokio::time::sleep(std::time::Duration::from_millis(25)).await;
2380        }
2381        assert_eq!(
2382            resumed_seqs,
2383            (RESUME_FROM..TOTAL as u64).collect::<Vec<_>>(),
2384            "a resume must start AT its cursor, not one past it"
2385        );
2386    }
2387
2388    /// A send that IS dispatched still puts the `user_message` on the
2389    /// stream first, ahead of every assistant delta for that turn. Moving the
2390    /// emit into the turn must not reorder it behind the turn's own output.
2391    #[tokio::test]
2392    async fn a_dispatched_send_emits_the_user_message_before_any_delta() {
2393        let repo = tempfile::tempdir().unwrap();
2394        init_repo(repo.path());
2395        let (state, _journal) = state();
2396        let script: Arc<dyn TurnGenerator> = Arc::new(Script {
2397            turns: vec![turn("here is what I would change", json!([]))],
2398            cursor: AtomicUsize::new(0),
2399        });
2400        let id = start(&state, repo.path(), script).await;
2401        let entry = get_discussion(&state, &id).await.unwrap();
2402
2403        let sent = send_message(&state, &id, "owner-1", "what should this change do?")
2404            .await
2405            .unwrap();
2406        assert_eq!(
2407            sent["seq"], 0,
2408            "the reported cursor is the user_message's own seq"
2409        );
2410        wait_for_turn_complete(&entry).await;
2411
2412        let events = entry.events.lock().await;
2413        assert!(
2414            matches!(events[0].kind, DiscussEventKind::UserMessage { .. }),
2415            "the operator's message must be the turn's first event, got: {:?}",
2416            events[0].kind
2417        );
2418        assert!(
2419            events.len() > 1,
2420            "the turn produced nothing to order against"
2421        );
2422        assert!(
2423            !events[1..]
2424                .iter()
2425                .any(|e| matches!(e.kind, DiscussEventKind::UserMessage { .. })),
2426            "exactly one user_message per send"
2427        );
2428    }
2429
2430    /// A subscriber that has stopped reading is its own problem: it is SHED,
2431    /// and the turn it was watching completes anyway.
2432    ///
2433    /// Half of this was never pinned. When the drain performed the sends
2434    /// itself, a half-open board (no FIN, no RST — writes park forever) held
2435    /// the drain for `DISCUSS_SEND_TIMEOUT` per event, so the next `Emit` sat
2436    /// unprocessed and every `entry.emit(…).await` inside `run_turn` waited on
2437    /// it: one dead board stalled the whole TURN. The turn here must complete
2438    /// while the wedge is still in place, on a clock well inside that deadline.
2439    #[tokio::test]
2440    async fn a_wedged_subscriber_is_shed_and_the_turn_still_completes() {
2441        let repo = tempfile::tempdir().unwrap();
2442        init_repo(repo.path());
2443        let (state, _journal) = state();
2444        let script: Arc<dyn TurnGenerator> = Arc::new(Script {
2445            turns: vec![turn("here is what I would change", json!([]))],
2446            cursor: AtomicUsize::new(0),
2447        });
2448        let id = start(&state, repo.path(), script).await;
2449        let entry = get_discussion(&state, &id).await.unwrap();
2450
2451        let (channel, _frames) = capturing_channel();
2452        let owner = state
2453            .create_session("owner-1", channel.clone())
2454            .await
2455            .unwrap();
2456        let unsubscribed = Arc::strong_count(&channel);
2457        handle_discuss_subscribe(
2458            &rpc_req(json!({ "discussion_id": id, "from_seq": 0 })),
2459            &state,
2460            &owner,
2461        )
2462        .await
2463        .unwrap();
2464        assert_eq!(
2465            Arc::strong_count(&channel),
2466            unsubscribed + 1,
2467            "the lane must hold this subscriber's channel"
2468        );
2469
2470        // Half-open from here on: writes never fail, they just never finish.
2471        let stuck = channel.write.lock().await;
2472
2473        let started = std::time::Instant::now();
2474        send_message(&state, &id, "owner-1", "what should this change do?")
2475            .await
2476            .unwrap();
2477        let mut completed = false;
2478        for _ in 0..120 {
2479            if entry
2480                .events
2481                .lock()
2482                .await
2483                .iter()
2484                .any(|e| matches!(e.kind, DiscussEventKind::TurnComplete {}))
2485            {
2486                completed = true;
2487                break;
2488            }
2489            tokio::time::sleep(std::time::Duration::from_millis(25)).await;
2490        }
2491        assert!(
2492            completed && started.elapsed() < DISCUSS_SEND_TIMEOUT,
2493            "the turn must not wait on a wedged subscriber's socket ({:?} elapsed)",
2494            started.elapsed()
2495        );
2496
2497        // ...and the lane is shed rather than carried: its queue fills, the
2498        // drain's `try_send` fails, and dropping the `Subscriber` aborts the
2499        // task parked on that socket — releasing the channel handle it pinned.
2500        for i in 0..(DISCUSS_SUBSCRIBER_QUEUE + 64) {
2501            entry
2502                .emit(DiscussEventKind::AssistantDelta {
2503                    text: format!("overflow-{i}"),
2504                })
2505                .await;
2506        }
2507        let mut shed = false;
2508        for _ in 0..200 {
2509            if Arc::strong_count(&channel) == unsubscribed {
2510                shed = true;
2511                break;
2512            }
2513            tokio::time::sleep(std::time::Duration::from_millis(25)).await;
2514        }
2515        assert!(
2516            shed,
2517            "a subscriber that is not draining must be shed, not retained"
2518        );
2519        drop(stuck);
2520    }
2521
2522    /// A `send` whose dispatch is refused must leave no unanswered operator
2523    /// question behind — not in the transcript, and not on the wire.
2524    ///
2525    /// `InFlightGuard` frees the discussion on that path, so `is_answering()`
2526    /// reads false — and `promote` and `coder.start { discussion_id }` gate on
2527    /// exactly that. The transcript still ended in a question no turn answered,
2528    /// which sailed through both guards and became the distillation input those
2529    /// guards exist to prevent: a confident intent invented from a question
2530    /// nobody replied to.
2531    ///
2532    /// The `user_message` event had the same hole for the same reason: it was
2533    /// emitted BEFORE the dispatch, so a refused send still put the operator's
2534    /// question in the replay buffer and on every subscriber while the
2535    /// transcript row rolled back — and the board's discussion pane rendered
2536    /// that question followed by permanent silence. The emit now happens inside
2537    /// the turn, so it and the transcript row commit or roll back together.
2538    #[tokio::test]
2539    async fn a_refused_send_leaves_no_unanswered_turn_in_the_transcript() {
2540        let repo = tempfile::tempdir().unwrap();
2541        init_repo(repo.path());
2542        let (state, _journal) = state();
2543        let calls = Arc::new(AtomicUsize::new(0));
2544        let script: Arc<dyn TurnGenerator> = Arc::new(Counting {
2545            calls: calls.clone(),
2546        });
2547        let id = start(&state, repo.path(), script).await;
2548        let entry = get_discussion(&state, &id).await.unwrap();
2549
2550        // Latch the turn slot closed WITHOUT removing the registry entry, so
2551        // the send reaches the dispatch and is refused THERE — the window a
2552        // racing `close` actually wins.
2553        entry.cancel_turn();
2554        let err = send_message(&state, &id, "owner-1", "should we rewrite the scheduler?")
2555            .await
2556            .unwrap_err();
2557        assert!(
2558            err.contains("closed while your message was being dispatched"),
2559            "expected a refused dispatch, got: {err}"
2560        );
2561
2562        assert!(
2563            !entry.is_answering(),
2564            "a refused dispatch must not strand `in_flight`"
2565        );
2566        assert!(
2567            entry.transcript_is_empty(),
2568            "a question no turn will answer must not survive in the transcript: {:?}",
2569            lock(&entry.transcript)
2570        );
2571        assert!(
2572            !entry
2573                .events
2574                .lock()
2575                .await
2576                .iter()
2577                .any(|e| matches!(e.kind, DiscussEventKind::UserMessage { .. })),
2578            "...nor reach the replay buffer and every subscriber"
2579        );
2580        // ...and the guards that read the transcript agree.
2581        let err = promote(&state, &id, "owner-1").await.unwrap_err();
2582        assert!(
2583            err.contains("no turns yet"),
2584            "promote must refuse an empty discussion rather than distill a stranded \
2585             question: {err}"
2586        );
2587        assert!(
2588            constraints_for_start(&state, &id).await.unwrap().is_empty(),
2589            "coder.start must not distill constraints from a stranded question"
2590        );
2591        assert_eq!(
2592            calls.load(Ordering::SeqCst),
2593            0,
2594            "no turn ran, so nothing reached the model"
2595        );
2596    }
2597
2598    /// The drain assigns `seq` under the buffer lock as the only writer, so the
2599    /// buffer is strictly ordered even when emits are produced concurrently.
2600    #[tokio::test]
2601    async fn concurrent_emits_stay_seq_ordered_in_the_buffer() {
2602        let repo = tempfile::tempdir().unwrap();
2603        init_repo(repo.path());
2604        let (state, _journal) = state();
2605        let script: Arc<dyn TurnGenerator> = Arc::new(Script {
2606            turns: vec![],
2607            cursor: AtomicUsize::new(0),
2608        });
2609        let id = start(&state, repo.path(), script).await;
2610        let entry = get_discussion(&state, &id).await.unwrap();
2611
2612        let mut tasks = Vec::new();
2613        for i in 0..50 {
2614            let e = entry.clone();
2615            tasks.push(tokio::spawn(async move {
2616                e.emit(DiscussEventKind::AssistantDelta {
2617                    text: format!("chunk-{i}"),
2618                })
2619                .await
2620            }));
2621        }
2622        for t in tasks {
2623            t.await.unwrap();
2624        }
2625
2626        let events = entry.events.lock().await;
2627        assert_eq!(events.len(), 50);
2628        for (i, e) in events.iter().enumerate() {
2629            assert_eq!(e.seq, i as u64, "buffer must be in seq order");
2630        }
2631    }
2632
2633    #[test]
2634    fn discuss_event_json_shape_is_ws_friendly() {
2635        let e = DiscussEvent {
2636            discussion_id: "disc-x".into(),
2637            seq: 7,
2638            ts: 1,
2639            kind: DiscussEventKind::AssistantDelta {
2640                text: "hello".into(),
2641            },
2642        };
2643        let v = serde_json::to_value(&e).unwrap();
2644        assert_eq!(v["type"], "assistant_delta");
2645        assert_eq!(v["text"], "hello");
2646        assert_eq!(v["seq"], 7);
2647        assert_eq!(v["discussion_id"], "disc-x");
2648
2649        let v = serde_json::to_value(DiscussEvent {
2650            discussion_id: "disc-x".into(),
2651            seq: 8,
2652            ts: 1,
2653            kind: DiscussEventKind::TurnComplete {},
2654        })
2655        .unwrap();
2656        assert_eq!(v["type"], "turn_complete");
2657    }
2658}