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//! Model transcripts and action receipts are checkpointed through the daemon's
62//! existing assistant oplog. The live runtime, substrate, replay stream and
63//! connection ownership are process-local: `start { resume_id }` reconstructs
64//! them from a checkpoint and a local repository/owner binding. Discussions
65//! are **owned by the connection that opened
66//! them**: only that connection may send to, subscribe to, promote or close
67//! them, and closing it closes the discussion and cancels any in-flight turn,
68//! because a detached turn would keep billing model tokens to nobody. Bounded
69//! three ways — [`MAX_OPEN_DISCUSSIONS`], [`DISCUSSION_IDLE_TTL_SECS`], and the
70//! per-discussion buffer/transcript caps.
71//!
72//! ## Event fanout
73//!
74//! One **drain task per discussion** owns the subscriber set. Emits, attaches
75//! and detaches are commands on its channel, so a single task serializes them:
76//! `seq` is assigned under the buffer lock by the only writer (no out-of-order
77//! buffer), an attach replays everything buffered *before* the next emit is
78//! processed (no gap, no duplicate), and — unlike the `coder.event` path — no
79//! lock is ever held across a send.
80//!
81//! The drain does not send, though: **each subscriber owns a bounded queue and
82//! its own sender task**. That is the part that makes a wedged subscriber
83//! merely its own problem. When the drain itself performed the sends, an
84//! untimed write to a half-open socket blocked the drain, so the *next* `Emit`
85//! command sat unprocessed — and since every `entry.emit(…).await` inside
86//! [`run_turn`] waits for its `seq`, one wedged board stalled the whole
87//! **turn**, not just its stream. Now the drain only `try_send`s into each
88//! subscriber's queue: a subscriber that cannot keep up (queue full, or a send
89//! past [`DISCUSS_SEND_TIMEOUT`]) is **shed**, and the turn never waits on a
90//! socket.
91
92use std::collections::HashMap;
93use std::path::{Path, PathBuf};
94use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
95use std::sync::{Arc, Mutex as StdMutex, MutexGuard};
96
97use serde::{Deserialize, Serialize};
98use serde_json::{json, Value};
99use tokio::sync::{mpsc, oneshot};
100
101use super::discussion_record::DiscussionRecord;
102use crate::assistant::governance::AssistantDurability;
103use crate::assistant::{
104    bind_default_substrate, build_assistant_runtime_with_tools, prompt, AssistantConfig,
105    AssistantService,
106};
107use crate::coder::native_loop::TurnGenerator;
108use crate::handler::JsonRpcMessage;
109use crate::session::{ClientSession, ServerState, WsChannel};
110
111const DISCUSSION_MUTATION_REFUSAL: &str = "This conversation stage is read-only; this is not a file-permission problem or a user refusing the requested change. Use prepare_coding_task with the requested change and constraints so the user can review it and start coding. Do not change file permissions or ask the user to do so.";
112
113/// Turn cap for one discussion reply. A discussion reads and reasons; it never
114/// edits, so it has no repair loop to spend turns on.
115const DISCUSS_MAX_TURNS: u32 = 12;
116
117/// Attempts allowed when distilling a transcript into an intent. Same bounded
118/// shape as `derive_contract`: the output is structured JSON, so a malformed
119/// reply is worth one retry, not an unbounded loop.
120const PROMOTE_MAX_ATTEMPTS: u32 = 3;
121
122/// Concurrent open discussions per daemon. Each pins an `AssistantService`, a
123/// `Runtime`, and an open runtime session, so they are not free; a board opens
124/// one at a time and an operator juggling more than a handful has lost track.
125///
126/// Enforced by [`ServerState::coder_discussion_slots`], a semaphore whose
127/// permit is taken before any of `start_discussion`'s async work and lives
128/// inside the admitted [`DiscussionEntry`] — NOT by counting the registry, which
129/// was a TOCTOU check that bounded nothing under pipelined starts.
130///
131/// [`ServerState::coder_discussion_slots`]: crate::session::ServerState
132pub(crate) const MAX_OPEN_DISCUSSIONS: usize = 8;
133
134/// A discussion with no activity for this long is reaped on the next
135/// `coder.discuss.start`. Long enough to step away from a train of thought,
136/// short enough that a forgotten one does not pin a runtime overnight.
137const DISCUSSION_IDLE_TTL_SECS: u64 = 60 * 60;
138
139/// Retained events per discussion. The oldest are dropped past this; a replay
140/// from a trimmed cursor returns what survives (`events_replayed` says how
141/// much) rather than growing without bound on a long conversation.
142const DISCUSS_EVENT_BUFFER_MAX: usize = 2000;
143
144/// Transcript turns retained for distillation. `promote` is a summarization
145/// call, so the recent exchange is what carries the intent; keeping everything
146/// eventually builds a prompt no model window holds.
147const TRANSCRIPT_MAX_TURNS: usize = 40;
148
149/// Turns handed to `distill`. The most recent slice of the retained transcript
150/// — the tail is where the operator converged.
151const DISTILL_WINDOW_TURNS: usize = 12;
152
153/// Byte cap on one operator message.
154///
155/// tungstenite accepts up to 64 MiB per frame, and an accepted message is
156/// cloned into the transcript, cloned again into the event buffer, and rendered
157/// into the distill prompt — so without a cap, 40 sequential 50 MB sends retain
158/// gigabytes per discussion and make `promote` build a prompt no window holds.
159/// `summarize_repo` is head-capped for exactly this reason; operator text needs
160/// the same. Generous for prose — this is a conversation, not a file upload.
161const DISCUSS_MESSAGE_MAX_BYTES: usize = 64 * 1024;
162
163/// Depth of one subscriber's outbound frame queue.
164///
165/// Must exceed [`DISCUSS_EVENT_BUFFER_MAX`] so a legitimate
166/// `subscribe { from_seq: 0 }` replay — up to a full buffer, queued in one go —
167/// is never mistaken for a slow consumer. Past that, a subscriber this far
168/// behind is not reading.
169const DISCUSS_SUBSCRIBER_QUEUE: usize = DISCUSS_EVENT_BUFFER_MAX + 128;
170
171/// How long one frame may take to reach a subscriber's socket before that
172/// subscriber is shed. A half-open peer never fails a write — it parks forever,
173/// holding the socket's write half. Matches the coder fanout's deadline.
174const DISCUSS_SEND_TIMEOUT: std::time::Duration = crate::coder::rpc::FANOUT_WRITE_TIMEOUT;
175
176/// Live discussions keyed by `discussion_id`.
177pub type DiscussionMap = HashMap<String, Arc<DiscussionEntry>>;
178
179/// Take a `std` lock without letting a poisoned mutex become permanent.
180///
181/// A panic anywhere under one of these locks would otherwise brick the
182/// discussion for its whole lifetime — and the first panic is swallowed by the
183/// detached turn task, so the operator would see an inexplicably dead
184/// conversation with no error. The data behind each of these is a plain
185/// `Vec`/`Option`; a torn write is not a safety problem here.
186fn lock<T>(m: &StdMutex<T>) -> MutexGuard<'_, T> {
187    m.lock().unwrap_or_else(|e| e.into_inner())
188}
189
190/// One event in a discussion's stream. `seq` is monotonic per discussion so a
191/// client can resume from a cursor, exactly like `CoderEvent`.
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct DiscussEvent {
194    pub discussion_id: String,
195    pub seq: u64,
196    pub ts: u64,
197    #[serde(flatten)]
198    pub kind: DiscussEventKind,
199}
200
201/// What happened in a discussion. Serialized with `"type":"snake_case_name"`,
202/// tagged the same way [`CoderEventKind`](super::session::CoderEventKind) is.
203#[derive(Debug, Clone, Serialize, Deserialize)]
204#[serde(tag = "type", rename_all = "snake_case")]
205pub enum DiscussEventKind {
206    UserMessage {
207        text: String,
208    },
209    /// A streaming chunk of the model's reply.
210    AssistantDelta {
211        text: String,
212    },
213    /// The complete assistant turn.
214    AssistantMessage {
215        text: String,
216    },
217    ToolCall {
218        tool: String,
219        params_preview: String,
220    },
221    ToolResult {
222        tool: String,
223        ok: bool,
224        preview: String,
225    },
226    TaskPrepared {
227        proposed_intent: String,
228        constraints: Vec<String>,
229    },
230    TurnComplete {},
231    Error {
232        message: String,
233    },
234}
235
236/// A command for a discussion's drain task — the single owner of its
237/// subscriber set and the only thing that writes its buffer or sends a frame.
238enum StreamCmd {
239    Emit(DiscussEventKind, oneshot::Sender<u64>),
240    Attach {
241        client_id: String,
242        channel: Arc<WsChannel>,
243        from_seq: u64,
244        replayed: oneshot::Sender<u64>,
245    },
246    Detach(String),
247}
248
249/// The in-flight turn's handle and the discussion's terminal `closed` latch,
250/// deliberately behind one lock.
251///
252/// They were separate, and the gap between them orphaned model loops: `close`
253/// read `turn_task` (still `None`, because `send_message` stores the handle
254/// only *after* its first `emit().await`), found nothing to abort, and removed
255/// the entry from the registry — then `send_message` resumed and spawned a turn
256/// against a discussion nothing could reach any more. It billed up to
257/// [`DISCUSS_MAX_TURNS`] turns against a live provider with no way to stop it.
258/// Publishing "this discussion is closed" and "here is the turn to abort"
259/// through the same lock closes that window in both directions: a close either
260/// aborts the running turn or latches `closed` so the turn is never spawned.
261#[derive(Default)]
262struct TurnSlot {
263    /// Set once, terminally, by [`DiscussionEntry::cancel_turn`]. Every caller
264    /// of `cancel_turn` also removes the entry from the registry, so there is
265    /// no legitimate reopen.
266    closed: bool,
267    handle: Option<tokio::task::JoinHandle<()>>,
268}
269
270/// Clears `in_flight` on EVERY exit path, including a cancelled or panicking
271/// handler future.
272///
273/// `in_flight` was set by CAS in `send_message` and cleared only at the tail of
274/// the spawned turn task. Anything that dropped the handler future between
275/// those two points — the daemon's handler deadline is the reachable one, since
276/// `coder.discuss.send` is not deadline-exempt — left it `true` with no turn
277/// running. The discussion then answered "still answering the previous message"
278/// to every `send` and "still answering" to every `promote`, forever, and
279/// `reap_idle` runs only on the next `discuss.start`, so on a quiet daemon it
280/// was never reclaimed either. A latch that only one code path can release is
281/// a latch that leaks; this releases in `Drop`.
282struct InFlightGuard(Arc<DiscussionEntry>);
283
284impl Drop for InFlightGuard {
285    fn drop(&mut self) {
286        self.0.in_flight.store(false, Ordering::SeqCst);
287        self.0.touch();
288    }
289}
290
291/// Keeps the operator's turn in the transcript only if a reply turn was
292/// actually dispatched for it.
293///
294/// `send_message` records the turn before the `emit().await` it may be
295/// cancelled at, and before the dispatch that may be refused. `InFlightGuard`
296/// frees the discussion on those paths, but the transcript was left ending in
297/// an operator question with no reply — and that is exactly the input the
298/// `is_answering()` guards on `promote` and `coder.start { discussion_id }`
299/// exist to keep out of distillation. Those guards read "not answering", so a
300/// stranded question sails through them and the model invents a confident
301/// intent from a question nobody answered. Recording after the dispatch would
302/// let the spawned turn's `Assistant` row land first, so the row goes in early
303/// and comes back out on every path that did not dispatch.
304struct TurnRecordGuard {
305    entry: Arc<DiscussionEntry>,
306    text: String,
307    dispatched: bool,
308}
309
310impl Drop for TurnRecordGuard {
311    fn drop(&mut self) {
312        if !self.dispatched {
313            self.entry.rollback_turn("Operator", &self.text);
314        }
315    }
316}
317
318/// One live discussion.
319pub struct DiscussionEntry {
320    pub id: String,
321    /// The git repo the conversation is grounded in.
322    pub repo: PathBuf,
323    /// Cheap repo orientation, returned by `coder.discuss.start` so a caller
324    /// can show what the discussion can see.
325    pub repo_summary: String,
326    project_context: String,
327    pub created_at: u64,
328    pub model: Option<String>,
329    /// The connection that opened this discussion. Closing it closes the
330    /// discussion — see the module docs on lifetime.
331    owner_client_id: String,
332    principal: String,
333    record_root: PathBuf,
334    /// Replay buffer. Written **only** by the drain task, so it is always in
335    /// `seq` order; readable elsewhere for inspection.
336    pub events: Arc<tokio::sync::Mutex<Vec<DiscussEvent>>>,
337    /// Commands to the drain task.
338    cmds: mpsc::UnboundedSender<StreamCmd>,
339    /// Completed operator turns (what `turns` reports in `coder.discuss.list`).
340    turns: AtomicU64,
341    /// Whether a reply turn is running right now. A discussion is a
342    /// conversation: two overlapping turns interleave into one model thread and
343    /// silently lose one of them, so a second `send` is refused rather than
344    /// queued.
345    in_flight: AtomicBool,
346    start_lock: Arc<tokio::sync::Mutex<()>>,
347    /// Last activity, for the idle TTL.
348    last_active: AtomicU64,
349    /// The in-flight turn's task plus the terminal `closed` latch, under ONE
350    /// lock. See [`TurnSlot`] for why they cannot be separate.
351    turn_task: StdMutex<TurnSlot>,
352    /// This discussion's open-slot reservation, taken before any of
353    /// `start_discussion`'s async work and released when the entry drops.
354    _slot: tokio::sync::OwnedSemaphorePermit,
355    /// The grounded, read-only conversational service.
356    service: Arc<AssistantService>,
357    durability: Arc<crate::assistant::durability::LocalAssistantDurability>,
358    /// The model seam used for distillation (`promote`). Same injection style
359    /// `derive_contract` uses, so promote is testable with a scripted model.
360    generator: Arc<dyn TurnGenerator>,
361    /// Role-tagged plain-text transcript, kept for distillation. Deliberately
362    /// separate from the service's own message thread: promote must see the
363    /// conversation, not the tool plumbing. Capped at [`TRANSCRIPT_MAX_TURNS`].
364    transcript: StdMutex<Vec<(&'static str, String)>>,
365    /// The most recent `promote` result, cached so `coder.start
366    /// { discussion_id }` can fold the agreed constraints into contract
367    /// derivation without a second distillation call.
368    last_promote: StdMutex<Option<(String, Vec<String>)>>,
369    task_proposal: super::task_proposal::PreparedTask,
370}
371
372impl DiscussionEntry {
373    fn save_pending_task(&self, proposal: Option<(String, Vec<String>)>) -> Result<(), String> {
374        let mut record = DiscussionRecord::load(&self.record_root, &self.id, &self.principal)?;
375        record.pending_task = proposal;
376        record.save_model(&self.record_root)
377    }
378
379    /// Constraints agreed in this discussion, from the last `promote`.
380    pub fn constraints(&self) -> Vec<String> {
381        lock(&self.last_promote)
382            .as_ref()
383            .map(|(_, c)| c.clone())
384            .unwrap_or_default()
385    }
386
387    /// Whether a reply turn is running right now.
388    pub fn is_answering(&self) -> bool {
389        self.in_flight.load(Ordering::SeqCst)
390    }
391
392    fn touch(&self) {
393        self.last_active.store(now_secs(), Ordering::SeqCst);
394    }
395
396    fn idle_secs(&self) -> u64 {
397        now_secs().saturating_sub(self.last_active.load(Ordering::SeqCst))
398    }
399
400    fn record_turn(&self, role: &'static str, text: &str) {
401        if text.trim().is_empty() {
402            return;
403        }
404        let mut t = lock(&self.transcript);
405        t.push((role, text.to_string()));
406        // Bounded: drop from the front, keeping the recent exchange.
407        let len = t.len();
408        if len > TRANSCRIPT_MAX_TURNS {
409            t.drain(..len - TRANSCRIPT_MAX_TURNS);
410        }
411    }
412
413    /// Undo the most recent [`record_turn`](Self::record_turn) when it is still
414    /// the tail and still ours. Matching on both role and text is what keeps a
415    /// rollback from eating someone else's row if the transcript moved on.
416    fn rollback_turn(&self, role: &'static str, text: &str) {
417        let mut t = lock(&self.transcript);
418        if t.last().is_some_and(|(r, s)| *r == role && s == text) {
419            t.pop();
420        }
421    }
422
423    /// The most recent turns, rendered for distillation.
424    fn distill_transcript(&self) -> String {
425        let t = lock(&self.transcript);
426        let start = t.len().saturating_sub(DISTILL_WINDOW_TURNS);
427        t[start..]
428            .iter()
429            .map(|(role, text)| format!("{role}: {text}"))
430            .collect::<Vec<_>>()
431            .join("\n\n")
432    }
433
434    fn transcript_is_empty(&self) -> bool {
435        lock(&self.transcript).is_empty()
436    }
437
438    /// Append an event to the stream, returning its assigned `seq`.
439    ///
440    /// The drain assigns the seq under the buffer lock, so the buffer is always
441    /// ordered; this only waits for that assignment, never for a WS send.
442    async fn emit(&self, kind: DiscussEventKind) -> u64 {
443        let (tx, rx) = oneshot::channel();
444        if self.cmds.send(StreamCmd::Emit(kind, tx)).is_err() {
445            return 0; // drain gone (discussion closed) — nothing to stream to
446        }
447        rx.await.unwrap_or(0)
448    }
449
450    /// Stop an in-flight turn and latch the discussion closed: signal the loop,
451    /// drop the task, and make sure no turn that is still being dispatched can
452    /// start behind us.
453    ///
454    /// Terminal by construction — every caller (`close`, disconnect teardown,
455    /// `reap_idle`) also removes the entry from the registry.
456    fn cancel_turn(&self) {
457        self.durability.revoke();
458        self.service.cancel(&self.id);
459        {
460            let mut slot = lock(&self.turn_task);
461            slot.closed = true;
462            if let Some(handle) = slot.handle.take() {
463                handle.abort();
464            }
465        }
466        self.in_flight.store(false, Ordering::SeqCst);
467    }
468
469    /// Spawn the reply turn under the same lock `cancel_turn` latches, so a
470    /// close that raced the dispatch either aborts this turn or prevents it.
471    ///
472    /// Returns `false` when the discussion was closed before the dispatch
473    /// reached this point — the turn is then never spawned at all.
474    fn spawn_turn<F>(&self, make: F) -> bool
475    where
476        F: FnOnce() -> tokio::task::JoinHandle<()>,
477    {
478        let mut slot = lock(&self.turn_task);
479        if slot.closed {
480            return false;
481        }
482        // No await under this guard: `tokio::spawn` only queues the task.
483        slot.handle = Some(make());
484        true
485    }
486
487    fn summary_row(&self) -> Value {
488        json!({
489            "discussion_id": self.id,
490            "repo": self.repo,
491            "created_at": self.created_at,
492            "turns": self.turns.load(Ordering::SeqCst),
493        })
494    }
495}
496
497fn now_secs() -> u64 {
498    std::time::SystemTime::now()
499        .duration_since(std::time::UNIX_EPOCH)
500        .map(|d| d.as_secs())
501        .unwrap_or(0)
502}
503
504fn event_frame(event: &DiscussEvent) -> Option<String> {
505    serde_json::to_string(&json!({
506        "jsonrpc": "2.0",
507        "method": "coder.discuss.event",
508        "params": event,
509    }))
510    .ok()
511}
512
513/// One subscriber's outbound lane: a bounded frame queue plus the task that
514/// drains it onto that subscriber's socket.
515///
516/// One lane per subscriber is what decouples the stream from the turn. The
517/// drain hands frames over with `try_send` and never awaits a socket, so no
518/// subscriber can delay the `seq` reply the turn is blocked on.
519struct Subscriber {
520    frames: mpsc::Sender<String>,
521    task: tokio::task::JoinHandle<()>,
522}
523
524impl Drop for Subscriber {
525    /// Abort rather than let the queue drain: the task may be parked on a
526    /// half-open socket's write mutex, and that parked future is precisely what
527    /// keeps the write half alive past teardown.
528    fn drop(&mut self) {
529        self.task.abort();
530    }
531}
532
533fn spawn_subscriber(channel: Arc<WsChannel>) -> Subscriber {
534    let (frames, mut rx) = mpsc::channel::<String>(DISCUSS_SUBSCRIBER_QUEUE);
535    let task = tokio::spawn(async move {
536        while let Some(frame) = rx.recv().await {
537            if tokio::time::timeout(
538                DISCUSS_SEND_TIMEOUT,
539                crate::coder::rpc::send_frame(&channel, &frame),
540            )
541            .await
542            .is_err()
543            {
544                // Wedged socket. Ending the task drops the channel handle and
545                // closes the queue, so the drain sheds this subscriber on its
546                // next `try_send` instead of queueing for a peer that is gone.
547                break;
548            }
549        }
550    });
551    Subscriber { frames, task }
552}
553
554/// The per-discussion drain: the single writer of the buffer and the single
555/// owner of the subscriber set.
556///
557/// Because one task handles emits and attaches in order, an attach replays
558/// everything buffered so far and is registered before the next emit is
559/// processed — no gap and no duplicate — without holding any lock across a
560/// handoff. The drain itself never touches a socket: it `try_send`s into each
561/// subscriber's own queue, so a subscriber that has stopped reading is shed
562/// rather than allowed to stall the buffer, the next `Emit`, or the turn
563/// waiting on that `Emit`'s `seq`.
564fn spawn_discuss_drain(
565    discussion_id: String,
566    events: Arc<tokio::sync::Mutex<Vec<DiscussEvent>>>,
567) -> mpsc::UnboundedSender<StreamCmd> {
568    let (tx, mut rx) = mpsc::unbounded_channel::<StreamCmd>();
569    tokio::spawn(async move {
570        let mut subscribers: HashMap<String, Subscriber> = HashMap::new();
571        let mut next_seq: u64 = 0;
572        while let Some(cmd) = rx.recv().await {
573            match cmd {
574                StreamCmd::Emit(kind, reply) => {
575                    let seq = next_seq;
576                    next_seq += 1;
577                    let event = DiscussEvent {
578                        discussion_id: discussion_id.clone(),
579                        seq,
580                        ts: now_secs(),
581                        kind,
582                    };
583                    let frame = event_frame(&event);
584                    {
585                        let mut buffer = events.lock().await;
586                        buffer.push(event);
587                        let len = buffer.len();
588                        if len > DISCUSS_EVENT_BUFFER_MAX {
589                            buffer.drain(..len - DISCUSS_EVENT_BUFFER_MAX);
590                        }
591                    } // lock released BEFORE the handoff
592                    let _ = reply.send(seq);
593                    if let Some(frame) = &frame {
594                        // `try_send`, never `send`: a full queue means this
595                        // subscriber is not draining, and waiting for it is how
596                        // one wedged board used to stall the whole turn.
597                        subscribers.retain(|client_id, s| {
598                            let ok = s.frames.try_send(frame.clone()).is_ok();
599                            if !ok {
600                                tracing::warn!(
601                                    discussion_id = %discussion_id,
602                                    client_id = %client_id,
603                                    "discussion subscriber is not draining; dropping it"
604                                );
605                            }
606                            ok
607                        });
608                    }
609                }
610                StreamCmd::Attach {
611                    client_id,
612                    channel,
613                    from_seq,
614                    replayed,
615                } => {
616                    // Clone the frames under the lock, release, then queue.
617                    let frames: Vec<String> = {
618                        let buffer = events.lock().await;
619                        buffer
620                            .iter()
621                            .filter(|e| e.seq >= from_seq)
622                            .filter_map(event_frame)
623                            .collect()
624                    };
625                    let subscriber = spawn_subscriber(channel);
626                    // The queue is sized to hold a whole buffer replay, so this
627                    // only short-circuits if the peer's lane already died.
628                    let mut n = 0u64;
629                    for frame in frames {
630                        if subscriber.frames.try_send(frame).is_err() {
631                            break;
632                        }
633                        n += 1;
634                    }
635                    subscribers.insert(client_id, subscriber);
636                    let _ = replayed.send(n);
637                }
638                StreamCmd::Detach(client_id) => {
639                    subscribers.remove(&client_id);
640                }
641            }
642        }
643        // Discussion closed: every lane's task is aborted by `Subscriber::drop`.
644    });
645    tx
646}
647
648// ---------------------------------------------------------------------------
649// Orchestration (generation-injectable, transport-free)
650// ---------------------------------------------------------------------------
651
652/// Provision a discussion grounded in `repo`, owned by `owner_client_id`.
653///
654/// `engine` builds the read-only assistant runtime (tools, substrate, gates);
655/// `generator` is the model seam both the conversation and `promote` run on.
656/// Split so tests can drive a scripted model against a real temp repo.
657pub async fn start_discussion(
658    state: &Arc<ServerState>,
659    repo: &Path,
660    owner_client_id: &str,
661    engine: Arc<car_inference::InferenceEngine>,
662    generator: Arc<dyn TurnGenerator>,
663) -> Result<Value, String> {
664    open_discussion(
665        state,
666        repo,
667        owner_client_id,
668        engine,
669        generator,
670        owner_client_id,
671        None,
672    )
673    .await
674}
675
676pub(super) async fn open_discussion(
677    state: &Arc<ServerState>,
678    repo: &Path,
679    owner_client_id: &str,
680    engine: Arc<car_inference::InferenceEngine>,
681    generator: Arc<dyn TurnGenerator>,
682    principal: &str,
683    resume_id: Option<&str>,
684) -> Result<Value, String> {
685    open_discussion_with_model(
686        state,
687        repo,
688        owner_client_id,
689        engine,
690        generator,
691        principal,
692        resume_id,
693        None,
694    )
695    .await
696}
697
698#[allow(clippy::too_many_arguments)]
699async fn open_discussion_with_model(
700    state: &Arc<ServerState>,
701    repo: &Path,
702    owner_client_id: &str,
703    engine: Arc<car_inference::InferenceEngine>,
704    generator: Arc<dyn TurnGenerator>,
705    principal: &str,
706    resume_id: Option<&str>,
707    requested_model: Option<&str>,
708) -> Result<Value, String> {
709    let _recovery = match resume_id {
710        Some(_) => Some(state.coder_discussion_recovery.lock().await),
711        None => None,
712    };
713    let saved = resume_id
714        .map(|id| DiscussionRecord::load(&state.journal_dir, id, principal))
715        .transpose()?;
716    if let Some(record) = &saved {
717        if state
718            .coder_discussions
719            .lock()
720            .await
721            .contains_key(&record.id)
722        {
723            return Err(
724                "conversation is already open; close its other window before resuming".into(),
725            );
726        }
727    }
728    // `canonicalize` and the `git rev-parse` probe are blocking syscalls (the
729    // probe forks), so they go to a blocking worker rather than parking a tokio
730    // runtime thread on fork/exec.
731    let probe = repo.to_path_buf();
732    let repo = tokio::task::spawn_blocking(move || {
733        // The same root `coder.start` keys tasks by; a subdirectory here would
734        // fail the conversation's own task admission (repo mismatch).
735        super::rpc::repo_toplevel(&probe)
736            .map_err(|e| format!("{e} — discuss needs a repo to ground itself in"))
737    })
738    .await
739    .map_err(|e| format!("repo probe failed: {e}"))??;
740
741    if saved.as_ref().is_some_and(|record| record.repo != repo) {
742        return Err("saved conversation belongs to a different repository".into());
743    }
744    if let (Some(record), Some(requested)) = (&saved, requested_model) {
745        let requested = requested.trim();
746        let requested = if requested.is_empty() || requested == "auto" {
747            None
748        } else {
749            Some(requested)
750        };
751        if requested != record.model.as_deref() {
752            let runs =
753                coding_runs(state, &record.id, &repo, super::rpc::coder_state_dir()?).await?;
754            if runs.iter().any(|run| {
755                !matches!(
756                    run["state"].as_str(),
757                    Some("merged" | "reported" | "failed" | "abandoned")
758                )
759            }) {
760                return Err(
761                    "Finish or stop this conversation's active task before changing its model."
762                        .into(),
763                );
764            }
765        }
766    }
767
768    // Reap idle discussions before enforcing the cap, so a forgotten one from
769    // this morning never blocks a new one this afternoon.
770    reap_idle(state).await;
771    // RESERVE the slot before any of the work below. Counting the registry here
772    // and inserting after `bind_default_substrate` + `build_assistant_runtime`
773    // was a TOCTOU check: the daemon runs a connection's requests concurrently,
774    // so N pipelined starts all read the same count, all passed, and all built
775    // a runtime — the cap bounded nothing. The permit lives in the entry and
776    // comes back if any step below fails.
777    let slot = state
778        .coder_discussion_slots
779        .clone()
780        .try_acquire_owned()
781        .map_err(|_| {
782            format!(
783                "{MAX_OPEN_DISCUSSIONS} discussions are already open — close one with \
784                 coder.discuss.close before starting another"
785            )
786        })?;
787
788    let summarize = repo.clone();
789    let (repo_summary, project_context) = tokio::task::spawn_blocking(move || {
790        (
791            super::rpc::summarize_repo(&summarize),
792            super::project_context::project_context(&summarize).unwrap_or_default(),
793        )
794    })
795    .await
796    .map_err(|e| format!("repo context failed: {e}"))?;
797
798    // prefer_local = true, full_access = false ⇒ PermissionTier::ReadOnly:
799    // every write/shell escalates to the approval gate, which this surface
800    // auto-denies (see the `approval_pending` arm in `run_turn`). No Docker
801    // preflight either — a discussion must open promptly.
802    let mut env = bind_default_substrate(true, false, &repo, None).await;
803    // ...and the read tools are pinned to the repo too. Mutation-gating alone
804    // left `read_file`/`list_dir`/`find_files`/`grep_files` pointed at the
805    // whole filesystem, whose output streams to every subscriber.
806    env.clamp_reads = true;
807    let task_proposal = Arc::new(StdMutex::new(None));
808    let proposal_tool = Arc::new(super::task_proposal::TaskProposalTool(
809        task_proposal.clone(),
810    ));
811    let mut asm = build_assistant_runtime_with_tools(
812        engine.clone(),
813        env,
814        None,
815        None,
816        None,
817        None,
818        false,
819        vec![super::task_proposal::TaskProposalTool::definition()],
820        vec![proposal_tool],
821    )
822    .await?;
823    // Advertise the tools useful before a coding task starts. The general
824    // assistant also knows about mail, calendars, media and file mutations;
825    // offering those here invites calls this conversation will refuse and
826    // needlessly enlarges every inference request. Keep the shared executor
827    // and approval gates intact for stale/hallucinated calls.
828    asm.tools.retain(|tool| {
829        matches!(
830            tool["name"].as_str(),
831            Some(
832                "read_file"
833                    | "list_dir"
834                    | "find_files"
835                    | "grep_files"
836                    | "calculate"
837                    | "web_search"
838                    | "http_request"
839                    | "prepare_coding_task"
840            )
841        )
842    });
843    let system = format!(
844        "{}\n\n{project_context}\n\nRepository guidance applies to your analysis and proposed work; it does not expand the read-only permissions below.\n\nYou are the coding assistant for repository {}. \
845         When the user asks you to implement or fix something, call prepare_coding_task with \
846         their requested change and constraints. This is how you begin implementation from \
847         this conversation. The interface will show an editable task and verification review \
848         before execution. For questions and planning-only requests, inspect the repository \
849         and answer without preparing a task. Use real paths. \
850         The current conversation tools can inspect the repository and prepare coding tasks; \
851         file edits and shell commands run in the subsequent coding task. A refused write \
852         in this stage is NOT evidence that the file is read-only or that the user declined \
853         implementation. Do not repeat earlier file-permission claims without current evidence \
854         or ask the user to change permissions to start coding. Use prepare_coding_task instead. \
855         Reads outside the repository remain refused. Do not require the user to know a \
856         command or runtime API. Preparing is not execution: never say files have changed \
857         or work has started.",
858        prompt::chat_prompt(&asm.identity, &asm.description, &asm.tools),
859        repo.display()
860    );
861    let model = match requested_model {
862        Some(value) if value.trim().is_empty() || value.trim() == "auto" => None,
863        Some(value) => Some(value.trim().to_string()),
864        None => saved.as_ref().and_then(|record| record.model.clone()),
865    };
866    if let Some(model) = &model {
867        let schema = engine.model_schema(model).ok_or_else(|| {
868            format!("Unknown model '{model}'. Use `car models list --capability tool_use` to choose a model, or --model auto to clear the saved choice.")
869        })?;
870        if !schema
871            .capabilities
872            .contains(&car_inference::schema::ModelCapability::ToolUse)
873        {
874            return Err(format!(
875                "Model '{model}' cannot call repository tools. Choose a tool-capable model with `car models list --capability tool_use`, or use --model auto."
876            ));
877        }
878    }
879    let generator: Arc<dyn TurnGenerator> = match &model {
880        Some(model) => Arc::new(super::discussion_model::DiscussionModel {
881            inner: generator,
882            model: model.clone(),
883        }),
884        None => generator,
885    };
886    let cfg = AssistantConfig {
887        model: model.clone(),
888        strict_model: model.is_some(),
889        max_turns: DISCUSS_MAX_TURNS,
890        tools: asm.tools.clone(),
891        gated_tools: asm.gated_tools.clone(),
892        approval_policy: None,
893        // A discussion writes nothing — including durable memory. Leaving the
894        // proactive-memory bank unbound keeps `remember` out of the loop's
895        // automatic pass; the tool itself is gated and auto-denied anyway.
896        proactive_memory: None,
897        tool_memory: None,
898        tool_labels: None,
899        // A discussion has no task list: it executes nothing, so there is no
900        // run for #814's per-turn state block to describe.
901        todos: None,
902        // The shipped default, like every other production call site — #813's
903        // A/B has been run and chose it; a discussion is not where that gets
904        // re-decided.
905        value_store_previews: crate::assistant::agent_loop::VALUE_STORE_PREVIEWS_DEFAULT,
906        response_format: None,
907        context_window_override: None,
908        refuse_unadvertised_tools: false,
909        response_format_validator: None,
910        delegate_budget: None,
911    };
912    let mut record = saved.unwrap_or_else(|| DiscussionRecord {
913        id: format!("disc-{}", uuid::Uuid::new_v4().simple()),
914        repo: repo.clone(),
915        principal: principal.to_string(),
916        created_at: now_secs(),
917        model: model.clone(),
918        pending_task: None,
919    });
920    record.model = model.clone();
921    let id = record.id.clone();
922    let durability = Arc::new(crate::assistant::durability::LocalAssistantDurability::new(
923        state.sync_subsystem()?,
924        id.clone(),
925        repo.clone(),
926    ));
927    let mut messages = if resume_id.is_some() {
928        durability
929            .load_checkpoint(&id)
930            .await?
931            .ok_or("saved conversation has no durable transcript")?
932            .messages
933    } else {
934        Vec::new()
935    };
936    // Rebind current runtime instructions while retaining exact provider/tool
937    // history. Interrupted tool exchanges are reconciled by AssistantService.
938    if let Some(car_inference::Message::System { content }) = messages.first_mut() {
939        *content = system.clone();
940    } else {
941        messages.insert(
942            0,
943            car_inference::Message::System {
944                content: system.clone(),
945            },
946        );
947    }
948    durability
949        .checkpoint(
950            &id,
951            &messages,
952            if resume_id.is_some() {
953                "conversation_resume"
954            } else {
955                "conversation_open"
956            },
957            None,
958        )
959        .await?;
960    if resume_id.is_none() {
961        record.save(&state.journal_dir)?;
962    } else if requested_model.is_some() {
963        record.save_model(&state.journal_dir)?;
964    }
965    let service = Arc::new(AssistantService::new_durable(
966        generator.clone(),
967        Arc::new(asm.runtime),
968        cfg,
969        system,
970        durability.clone(),
971        repo.clone(),
972    ));
973
974    let events = Arc::new(tokio::sync::Mutex::new(Vec::new()));
975    let cmds = spawn_discuss_drain(id.clone(), events.clone());
976    let entry = Arc::new(DiscussionEntry {
977        id: id.clone(),
978        repo: repo.clone(),
979        repo_summary: repo_summary.clone(),
980        project_context,
981        created_at: record.created_at,
982        model: model.clone(),
983        owner_client_id: owner_client_id.to_string(),
984        principal: principal.to_string(),
985        record_root: state.journal_dir.clone(),
986        events,
987        cmds,
988        turns: AtomicU64::new(0),
989        in_flight: AtomicBool::new(false),
990        start_lock: Arc::new(tokio::sync::Mutex::new(())),
991        last_active: AtomicU64::new(now_secs()),
992        turn_task: StdMutex::new(TurnSlot::default()),
993        _slot: slot,
994        service,
995        durability,
996        generator,
997        transcript: StdMutex::new(Vec::new()),
998        last_promote: StdMutex::new(record.pending_task.clone()),
999        task_proposal,
1000    });
1001    // Project recorded conversation and tool activity without executing it.
1002    // Checkpoints do not retain a typed success flag for every tool result, so
1003    // do not invent result status from arbitrary tool-output text. Full history
1004    // beyond model-context compaction still requires the presentation log.
1005    for message in messages {
1006        let (role, kind, text) = match message {
1007            car_inference::Message::User { content } => ("Operator", true, content),
1008            car_inference::Message::Assistant {
1009                content,
1010                tool_calls,
1011                ..
1012            } if tool_calls.is_empty() => ("Assistant", false, content),
1013            car_inference::Message::Assistant { tool_calls, .. } => {
1014                for call in tool_calls {
1015                    entry
1016                        .emit(DiscussEventKind::ToolCall {
1017                            tool: call.name,
1018                            params_preview: preview(
1019                                &serde_json::to_string(&call.arguments).unwrap_or_default(),
1020                            ),
1021                        })
1022                        .await;
1023                }
1024                continue;
1025            }
1026            _ => continue,
1027        };
1028        if text.trim().is_empty() {
1029            continue;
1030        }
1031        entry.record_turn(role, &text);
1032        if kind {
1033            entry.emit(DiscussEventKind::UserMessage { text }).await;
1034        } else {
1035            entry.turns.fetch_add(1, Ordering::SeqCst);
1036            entry
1037                .emit(DiscussEventKind::AssistantMessage { text })
1038                .await;
1039        }
1040    }
1041    if let Some((proposed_intent, constraints)) = record.pending_task {
1042        entry
1043            .emit(DiscussEventKind::TaskPrepared {
1044                proposed_intent,
1045                constraints,
1046            })
1047            .await;
1048    }
1049    state
1050        .coder_discussions
1051        .lock()
1052        .await
1053        .insert(id.clone(), entry);
1054
1055    Ok(json!({
1056        "discussion_id": id,
1057        "repo": repo,
1058        "repo_summary": repo_summary,
1059        "persistent": true,
1060        "resumed": resume_id.is_some(),
1061        "model": model,
1062    }))
1063}
1064
1065/// Close discussions idle past [`DISCUSSION_IDLE_TTL_SECS`].
1066async fn reap_idle(state: &Arc<ServerState>) {
1067    let stale: Vec<Arc<DiscussionEntry>> = {
1068        let open = state.coder_discussions.lock().await;
1069        open.values()
1070            .filter(|e| e.idle_secs() > DISCUSSION_IDLE_TTL_SECS)
1071            .cloned()
1072            .collect()
1073    };
1074    for entry in stale {
1075        entry.cancel_turn();
1076        state.coder_discussions.lock().await.remove(&entry.id);
1077    }
1078}
1079
1080async fn get_discussion(
1081    state: &Arc<ServerState>,
1082    discussion_id: &str,
1083) -> Result<Arc<DiscussionEntry>, String> {
1084    state
1085        .coder_discussions
1086        .lock()
1087        .await
1088        .get(discussion_id)
1089        .cloned()
1090        .ok_or_else(|| {
1091            format!(
1092                "no open discussion '{discussion_id}' — reopen a saved conversation with \
1093                 coder.discuss.start {{repo, resume_id}}, or start a new one"
1094            )
1095        })
1096}
1097
1098pub(super) async fn selected_model(
1099    state: &Arc<ServerState>,
1100    discussion_id: &str,
1101) -> Result<Option<String>, String> {
1102    Ok(get_discussion(state, discussion_id).await?.model.clone())
1103}
1104
1105/// Resolve a discussion **and prove the caller owns it**.
1106///
1107/// Ownership was recorded but only ever consulted by disconnect teardown, so
1108/// every `coder.discuss.*` method resolved by id alone: any connected client
1109/// could send into, subscribe to, promote, or close another connection's
1110/// discussion — closing one mid-turn was the sharp end, since it cancels a turn
1111/// the owner is watching. Discussions are already per-connection and die with
1112/// their connection, so refusing here is the same model, enforced.
1113pub(crate) async fn get_owned_discussion(
1114    state: &Arc<ServerState>,
1115    discussion_id: &str,
1116    client_id: &str,
1117) -> Result<Arc<DiscussionEntry>, String> {
1118    let entry = get_discussion(state, discussion_id).await?;
1119    if entry.owner_client_id != client_id {
1120        return Err(format!(
1121            "discussion '{discussion_id}' belongs to another connection — a discussion is \
1122             owned by the connection that opened it and closes with it; start your own with \
1123             coder.discuss.start"
1124        ));
1125    }
1126    Ok(entry)
1127}
1128
1129/// Send one operator message and run the reply turn.
1130///
1131/// Returns once the turn is dispatched and has emitted its first event,
1132/// carrying that event's `seq` (the `user_message`), so a caller that has not
1133/// yet subscribed can resume from exactly there without missing or replaying a
1134/// frame. A refused dispatch emits nothing at all.
1135///
1136/// **One turn at a time.** A `send` arriving while a turn is in flight is
1137/// REFUSED, not queued: both turns clone the same model thread and the last one
1138/// to finish overwrites the other, so the earlier exchange vanishes from the
1139/// conversation — and from what `promote` later distills. Refusing is the
1140/// honest answer; the caller retries when `turn_complete` lands.
1141pub async fn send_message(
1142    state: &Arc<ServerState>,
1143    discussion_id: &str,
1144    client_id: &str,
1145    text: &str,
1146) -> Result<Value, String> {
1147    let entry = get_owned_discussion(state, discussion_id, client_id).await?;
1148    if text.trim().is_empty() {
1149        return Err("discuss message is empty".to_string());
1150    }
1151    if text.len() > DISCUSS_MESSAGE_MAX_BYTES {
1152        return Err(format!(
1153            "that message is {} bytes; the limit is {DISCUSS_MESSAGE_MAX_BYTES}. A discussion \
1154             keeps every message in its transcript, its replay buffer, and its distillation \
1155             prompt — point at a file in the repo instead of pasting it",
1156            text.len()
1157        ));
1158    }
1159    if entry
1160        .in_flight
1161        .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
1162        .is_err()
1163    {
1164        return Err(format!(
1165            "{discussion_id} is still answering the previous message — wait for \
1166             `turn_complete` before sending another"
1167        ));
1168    }
1169    // Armed IMMEDIATELY after the CAS: if this future is dropped before the
1170    // turn owns it, the guard's Drop is the only thing that stops the
1171    // discussion latching "still answering" forever with nothing running.
1172    //
1173    // Held in an `Option` so a REFUSED dispatch leaves it here rather than
1174    // dropping it inside the `spawn_turn(…)` expression: it then drops at this
1175    // function's scope exit, AFTER `recorded` (declared below, so it drops
1176    // first) has rolled the transcript row back. Otherwise `in_flight` reads
1177    // false while the stranded operator row is still visible — the reverse of
1178    // the cancellation path's order.
1179    let mut guard = Some(InFlightGuard(entry.clone()));
1180    entry.touch();
1181    entry.record_turn("Operator", text);
1182    // ...and armed with it, for the same reason: a dispatch refused by a racing
1183    // `close` must not leave the transcript ending in an operator question no
1184    // turn will ever answer.
1185    let mut recorded = TurnRecordGuard {
1186        entry: entry.clone(),
1187        text: text.to_string(),
1188        dispatched: false,
1189    };
1190
1191    let task_entry = entry.clone();
1192    let task_state = state.clone();
1193    let prompt_text = text.to_string();
1194    // The `user_message` is emitted INSIDE the turn, as its first act — not
1195    // here, before the dispatch is known to have happened. Emitting it first
1196    // put it in the replay buffer and on every subscriber even when the
1197    // dispatch was refused and `TurnRecordGuard` rolled the transcript row
1198    // back: the board then rendered the operator's question followed by
1199    // permanent silence. Emitting from the turn makes the event and the
1200    // transcript row commit or roll back together, and makes the ordering
1201    // (`user_message` before any assistant delta for this turn) structural
1202    // rather than a scheduling accident.
1203    let (seq_tx, seq_rx) = oneshot::channel::<u64>();
1204    // Spawned under the turn-slot lock, so a `close` that raced this dispatch
1205    // either aborts the turn or stops it being spawned at all.
1206    let dispatched = entry.spawn_turn(|| {
1207        // Invalidate before spawning: a fast reply can prepare the next task
1208        // before send_message returns, and must not have its result erased.
1209        *lock(&entry.last_promote) = None;
1210        let guard = guard.take();
1211        tokio::spawn(async move {
1212            // The guard moves into the turn; it releases `in_flight` when the
1213            // turn ends, is aborted, or panics.
1214            let _guard = guard;
1215            let seq = task_entry
1216                .emit(DiscussEventKind::UserMessage {
1217                    text: prompt_text.clone(),
1218                })
1219                .await;
1220            let _ = seq_tx.send(seq);
1221            run_turn(task_state, task_entry, prompt_text).await;
1222        })
1223    });
1224    if !dispatched {
1225        return Err(format!(
1226            "{discussion_id} was closed while your message was being dispatched — nothing is \
1227             running; start a new discussion"
1228        ));
1229    }
1230    // A turn is running for this message now, so the transcript row stays.
1231    recorded.dispatched = true;
1232
1233    // The turn's first event, reported so a caller that has not yet subscribed
1234    // can resume from exactly there. 0 if the turn was aborted before it got
1235    // that far — same answer `emit` gives when the drain is already gone.
1236    let first_seq = seq_rx.await.unwrap_or(0);
1237    Ok(json!({ "ok": true, "seq": first_seq }))
1238}
1239
1240/// Read the same live/persisted session records used by the coding UI. This is
1241/// a projection, not a second task store. Binding both repository and discussion
1242/// prevents unrelated task history from entering a model request.
1243async fn coding_context(
1244    state: &Arc<ServerState>,
1245    entry: &DiscussionEntry,
1246) -> Result<String, String> {
1247    let dir = super::rpc::coder_state_dir()?;
1248    let mut rows = coding_runs(state, &entry.id, &entry.repo, dir).await?;
1249    let total = rows.len();
1250    rows.truncate(8);
1251    if total > rows.len() {
1252        rows.push(json!({"older_runs_omitted": total - rows.len()}));
1253    }
1254    if rows.is_empty() {
1255        return Ok(String::new());
1256    }
1257    Ok(format!(
1258        "Linked coding runs (current observations; older observations may be stale):\n{}\nThese runs use isolated worktrees. Repository reads still target the conversation repository; do not assume its checkout contains a run's changes. A passing check or published branch does not establish deployment. Ask for review of retained work before proposing to start over. After checkout delivery, a new task captures current checkout files, including later manual edits and deletions. After branch delivery, it starts from the recorded result commit. A retained native worktree with execution_stopped=true can be reopened by the next task. Other unfinished work still requires recovery checks.",
1259        serde_json::to_string(&rows).map_err(|e| e.to_string())?
1260    ))
1261}
1262
1263async fn coding_runs(
1264    state: &Arc<ServerState>,
1265    discussion_id: &str,
1266    repo: &Path,
1267    dir: PathBuf,
1268) -> Result<Vec<Value>, String> {
1269    let entries: Vec<_> = state
1270        .coder_sessions
1271        .lock()
1272        .await
1273        .values()
1274        .cloned()
1275        .collect();
1276    let mut rows = Vec::new();
1277    let mut live_ids = std::collections::HashSet::new();
1278    for entry in entries {
1279        let session = entry.session.lock().await;
1280        live_ids.insert(session.id.clone());
1281        if session.discussion_id.as_deref() == Some(discussion_id) && session.repo == repo {
1282            rows.push(coding_run_row(&session, true));
1283        }
1284    }
1285    let discussion_id = discussion_id.to_string();
1286    let repo = repo.to_path_buf();
1287    let saved = tokio::task::spawn_blocking(move || {
1288        super::session::CoderSession::list(&dir)
1289            .into_iter()
1290            .filter(|session| {
1291                !live_ids.contains(&session.id)
1292                    && session.discussion_id.as_deref() == Some(discussion_id.as_str())
1293                    && session.repo == repo
1294            })
1295            .map(|session| coding_run_row(&session, false))
1296            .collect::<Vec<_>>()
1297    })
1298    .await
1299    .map_err(|e| format!("read linked coding runs: {e}"))?;
1300    rows.extend(saved);
1301    let superseded: std::collections::HashSet<String> = rows
1302        .iter()
1303        .filter_map(|row| row["resumed_from"].as_str().map(str::to_string))
1304        .collect();
1305    rows.retain(|row| {
1306        !row["session_id"]
1307            .as_str()
1308            .is_some_and(|id| superseded.contains(id))
1309    });
1310    rows.sort_by_key(|row| std::cmp::Reverse(row["updated_at"].as_u64().unwrap_or(0)));
1311    Ok(rows)
1312}
1313
1314fn coding_run_row(session: &super::session::CoderSession, live: bool) -> Value {
1315    let omitted_guidance = session.steering_messages.len().saturating_sub(8);
1316    let guidance: Vec<Value> = session.steering_messages[omitted_guidance..]
1317        .iter()
1318        .map(|text| {
1319            json!({
1320                "text": text.chars().take(1000).collect::<String>(),
1321                "truncated": text.chars().count() > 1000,
1322            })
1323        })
1324        .collect();
1325    let checks: Vec<Value> = session.last_check_results.iter().take(20).map(|check| json!({
1326        "name": check.name.chars().take(160).collect::<String>(),
1327        "passed": check.passed,
1328        "exit_code": check.exit_code,
1329        "timed_out": check.timed_out,
1330        "deadline_clamped": check.deadline_clamped,
1331        "output_tail": check.output_tail.chars().rev().take(800).collect::<String>().chars().rev().collect::<String>(),
1332    })).collect();
1333    json!({
1334        "session_id": session.id, "state": session.state.as_str(), "live": live,
1335        "intent": session.intent.chars().take(1000).collect::<String>(),
1336        "updated_at": session.updated_at, "result_branch": session.result_branch,
1337        "result_commit": session.result_commit,
1338        "result_delivery": session.result_delivery,
1339        "resumed_from": session.resumed_from,
1340        "execution_stopped": session.execution_stopped,
1341        "operator_guidance": guidance,
1342        "operator_guidance_omitted": omitted_guidance,
1343        "engine": session.engine.label(),
1344        "worktree": session.workspace_path.as_ref().filter(|path| path.is_dir()),
1345        "error": session.error.as_ref().map(|error| error.chars().take(1000).collect::<String>()),
1346        "failure_kind": session.failure_kind, "checks": checks,
1347        "checks_omitted": session.last_check_results.len().saturating_sub(checks.len()),
1348    })
1349}
1350
1351/// Recover only a native task whose execution returned or was joined. The
1352/// start admission guard must remain held through adoption and registration.
1353pub(super) async fn retained_workspace(
1354    state: &Arc<ServerState>,
1355    discussion_id: &str,
1356    repo: &Path,
1357    dir: PathBuf,
1358) -> Result<Option<(String, PathBuf)>, String> {
1359    let rows = coding_runs(state, discussion_id, repo, dir.clone()).await?;
1360    let retained: Vec<_> = rows
1361        .iter()
1362        .filter(|row| {
1363            matches!(row["state"].as_str(), Some("failed" | "abandoned"))
1364                && row["worktree"].is_string()
1365        })
1366        .collect();
1367    if retained.len() > 1 {
1368        return Err("Multiple unfinished worktrees belong to this conversation. Review them before choosing work to continue.".into());
1369    }
1370    let Some(row) = retained.first() else {
1371        return Ok(None);
1372    };
1373    if row["engine"] != "native" || row["execution_stopped"] != true {
1374        return Err("The previous task's execution has not been confirmed stopped. Its work is retained; automatic recovery cannot safely reopen it yet.".into());
1375    }
1376    let path = PathBuf::from(
1377        row["worktree"]
1378            .as_str()
1379            .ok_or("missing retained worktree")?,
1380    )
1381    .canonicalize()
1382    .map_err(|e| format!("retained worktree: {e}"))?;
1383    let root = dir
1384        .join("worktrees")
1385        .canonicalize()
1386        .map_err(|e| e.to_string())?;
1387    if path.parent() != Some(root.as_path()) {
1388        return Err("retained worktree is outside this daemon's workspace directory".into());
1389    }
1390    Ok(Some((
1391        row["session_id"]
1392            .as_str()
1393            .ok_or("missing retained task id")?
1394            .to_string(),
1395        path,
1396    )))
1397}
1398
1399/// Branch deliveries continue from the saved revision. Checkout deliveries
1400/// continue from current files (including later user edits), captured by task
1401/// admission. Explicit caller bases win; legacy unknown results still refuse.
1402pub(super) async fn followup_base(
1403    state: &Arc<ServerState>,
1404    discussion_id: &str,
1405    repo: &Path,
1406    dir: PathBuf,
1407) -> Result<Option<String>, String> {
1408    let rows = coding_runs(state, discussion_id, repo, dir).await?;
1409    followup_base_from(&rows)
1410}
1411
1412fn followup_base_from(rows: &[Value]) -> Result<Option<String>, String> {
1413    for run in rows {
1414        match run["state"].as_str() {
1415            Some("merged") if rows.iter().any(|other| other["state"] == "merged"
1416                && other["updated_at"] == run["updated_at"]
1417                && other["result_commit"] != run["result_commit"]) => {
1418                return Err("Multiple deliveries have the same recorded timestamp. Choose an explicit base revision; CAR cannot safely infer their order.".into());
1419            }
1420            Some("merged") => {
1421                let commit = run["result_commit"].as_str().ok_or_else(|| "The previous run predates saved result revisions. Choose an explicit base revision before continuing; CAR will not silently start again from repository HEAD.".to_string())?;
1422                // Applied results already live in the checkout. Reusing their
1423                // old tree would erase later user edits/deletions from the next
1424                // task's view. None asks admission to capture current inputs.
1425                return Ok((run["result_delivery"] != "checkout").then(|| commit.to_string()));
1426            },
1427            Some("failed" | "abandoned") if !run["worktree"].is_null() => return Err(format!(
1428                "The previous run has unfinished changes at {}. Review that work before starting over; resuming that worktree is not yet supported.", run["worktree"].as_str().unwrap_or("the retained worktree")
1429            )),
1430            _ => {},
1431        }
1432    }
1433    Ok(None)
1434}
1435
1436/// Serialize task admission for one conversation through registration. The
1437/// caller holds this guard until start_session has published its live entry.
1438/// Unlike prompt advice, this prevents two concurrent builds of the same turn.
1439pub(super) async fn claim_coding_start(
1440    state: &Arc<ServerState>,
1441    discussion_id: &str,
1442    repo: &Path,
1443    dir: PathBuf,
1444) -> Result<tokio::sync::OwnedMutexGuard<()>, String> {
1445    if get_discussion(state, discussion_id).await?.repo != repo {
1446        return Err("The task repository differs from this conversation's repository.".into());
1447    }
1448    claim_coding_start_at(state, discussion_id, dir).await
1449}
1450
1451async fn claim_coding_start_at(
1452    state: &Arc<ServerState>,
1453    discussion_id: &str,
1454    dir: PathBuf,
1455) -> Result<tokio::sync::OwnedMutexGuard<()>, String> {
1456    let entry = get_discussion(state, discussion_id).await?;
1457    let guard = entry.start_lock.clone().lock_owned().await;
1458    // Recheck after waiting: closing a discussion must not leave a queued
1459    // launch with a stale entry that is no longer authorized to start.
1460    let current = get_discussion(state, discussion_id).await?;
1461    if !Arc::ptr_eq(&entry, &current) {
1462        return Err("conversation was reopened; retry the task".into());
1463    }
1464    let runs = coding_runs(state, discussion_id, &entry.repo, dir).await?;
1465    if let Some(run) = runs.iter().find(|row| {
1466        row.get("state").is_some_and(|state| {
1467            !matches!(
1468                state.as_str(),
1469                Some("merged" | "reported" | "failed" | "abandoned")
1470            )
1471        })
1472    }) {
1473        return Err(format!("Conversation already has unfinished work in {} ({}). Open it from /sessions to continue or cancel it before starting another task.", run["session_id"].as_str().unwrap_or("unknown"), run["state"].as_str().unwrap_or("unknown")));
1474    }
1475    Ok(guard)
1476}
1477
1478/// Drive one assistant turn, translating its wire events into discussion
1479/// events and auto-denying every approval escalation.
1480async fn run_turn(state: Arc<ServerState>, entry: Arc<DiscussionEntry>, text: String) {
1481    *lock(&entry.task_proposal) = None;
1482    if let Err(message) = entry.save_pending_task(None) {
1483        entry.emit(DiscussEventKind::Error { message }).await;
1484        entry.emit(DiscussEventKind::TurnComplete {}).await;
1485        return;
1486    }
1487    let context = match coding_context(&state, &entry).await {
1488        Ok(context) => context,
1489        Err(error) => {
1490            entry.emit(DiscussEventKind::Error { message: error }).await;
1491            entry.emit(DiscussEventKind::TurnComplete {}).await;
1492            return;
1493        }
1494    };
1495    let sink_entry = entry.clone();
1496    let assembled: Arc<StdMutex<String>> = Arc::new(StdMutex::new(String::new()));
1497    let sink_assembled = assembled.clone();
1498
1499    let service = entry.service.clone();
1500    // The sink resolves approvals on the same service it streams from, so it
1501    // needs its own handle rather than borrowing the one being called.
1502    let sink_service = service.clone();
1503    let id = entry.id.clone();
1504    service
1505        .handle_turn_with_context(
1506            &id,
1507            &text,
1508            None,
1509            None,
1510            Some(&context),
1511            move |payload: Value| {
1512                let entry = sink_entry.clone();
1513                let assembled = sink_assembled.clone();
1514                let service = sink_service.clone();
1515                async move {
1516                    let kind = payload.get("kind").and_then(Value::as_str).unwrap_or("");
1517                    match kind {
1518                        "token" => {
1519                            let delta = payload
1520                                .get("delta")
1521                                .and_then(Value::as_str)
1522                                .unwrap_or_default()
1523                                .to_string();
1524                            if delta.is_empty() {
1525                                return;
1526                            }
1527                            lock(&assembled).push_str(&delta);
1528                            entry
1529                                .emit(DiscussEventKind::AssistantDelta { text: delta })
1530                                .await;
1531                        }
1532                        "tool_call" => {
1533                            let tool = payload
1534                                .get("tool")
1535                                .and_then(Value::as_str)
1536                                .unwrap_or("tool")
1537                                .to_string();
1538                            let params_preview = payload
1539                                .get("params")
1540                                .map(|p| preview(&p.to_string()))
1541                                .unwrap_or_default();
1542                            entry
1543                                .emit(DiscussEventKind::ToolCall {
1544                                    tool,
1545                                    params_preview,
1546                                })
1547                                .await;
1548                        }
1549                        // The no-mutation boundary, enforced here rather than left
1550                        // to a human: a discussion never writes, so an escalation is
1551                        // answered immediately with "no" instead of parking a
1552                        // prompt nobody asked for (and timing out five minutes
1553                        // later, which is what the unresolved gate would do).
1554                        "approval_pending" => {
1555                            let tool = payload
1556                                .get("tool")
1557                                .and_then(Value::as_str)
1558                                .unwrap_or("tool")
1559                                .to_string();
1560                            if let Some(approval_id) =
1561                                payload.get("approval_id").and_then(Value::as_str)
1562                            {
1563                                service.deny_approval(
1564                                    approval_id,
1565                                    DISCUSSION_MUTATION_REFUSAL.to_string(),
1566                                );
1567                            }
1568                            entry
1569                                .emit(DiscussEventKind::ToolResult {
1570                                    tool,
1571                                    ok: false,
1572                                    preview: DISCUSSION_MUTATION_REFUSAL.to_string(),
1573                                })
1574                                .await;
1575                        }
1576                        "done" => {
1577                            let text = payload
1578                                .get("text")
1579                                .and_then(Value::as_str)
1580                                .unwrap_or_default()
1581                                .to_string();
1582                            let text = if text.trim().is_empty() {
1583                                lock(&assembled).clone()
1584                            } else {
1585                                text
1586                            };
1587                            entry.record_turn("Assistant", &text);
1588                            entry.turns.fetch_add(1, Ordering::SeqCst);
1589                            entry
1590                                .emit(DiscussEventKind::AssistantMessage { text })
1591                                .await;
1592                            let proposal = lock(&entry.task_proposal).take();
1593                            if let Some((proposed_intent, constraints)) = proposal {
1594                                if let Err(message) = entry.save_pending_task(Some((
1595                                    proposed_intent.clone(),
1596                                    constraints.clone(),
1597                                ))) {
1598                                    entry.emit(DiscussEventKind::Error { message }).await;
1599                                    entry.emit(DiscussEventKind::TurnComplete {}).await;
1600                                    return;
1601                                }
1602                                *lock(&entry.last_promote) =
1603                                    Some((proposed_intent.clone(), constraints.clone()));
1604                                entry
1605                                    .emit(DiscussEventKind::TaskPrepared {
1606                                        proposed_intent,
1607                                        constraints,
1608                                    })
1609                                    .await;
1610                            }
1611                            entry.emit(DiscussEventKind::TurnComplete {}).await;
1612                        }
1613                        "error" => {
1614                            let message = payload
1615                                .get("error")
1616                                .and_then(Value::as_str)
1617                                .unwrap_or("discussion turn failed")
1618                                .to_string();
1619                            entry.emit(DiscussEventKind::Error { message }).await;
1620                            entry.emit(DiscussEventKind::TurnComplete {}).await;
1621                        }
1622                        // The third terminal kind. A discussion runs the same
1623                        // `AssistantService` as chat, so a turn can be refused on
1624                        // the Parslee account here too — and without this arm the
1625                        // frame fell through to `_ => {}`: no error, no remedy, and
1626                        // crucially no `TurnComplete`, which is the event
1627                        // `docs/websocket-protocol.md` tells a client to wait for
1628                        // before sending again. The client was left holding a turn
1629                        // that had already ended.
1630                        //
1631                        // Rendered through `Error` rather than a new variant: the
1632                        // message IS the remedy, in the copy a person reads, and a
1633                        // client that already renders discussion errors shows it
1634                        // without changing.
1635                        "auth_required" => {
1636                            let message = payload
1637                                .get("message")
1638                                .and_then(Value::as_str)
1639                                .unwrap_or("this discussion needs a Parslee sign-in")
1640                                .to_string();
1641                            entry.emit(DiscussEventKind::Error { message }).await;
1642                            entry.emit(DiscussEventKind::TurnComplete {}).await;
1643                        }
1644                        _ => {}
1645                    }
1646                }
1647            },
1648        )
1649        .await;
1650}
1651
1652fn preview(s: &str) -> String {
1653    const CAP: usize = 200;
1654    if s.chars().count() <= CAP {
1655        return s.to_string();
1656    }
1657    let mut out: String = s.chars().take(CAP).collect();
1658    out.push('…');
1659    out
1660}
1661
1662/// Distill the discussion into a run intent + the constraints agreed in it.
1663///
1664/// **Starts nothing.** No worktree, no branch, no session — the caller shows
1665/// `proposed_intent` to the operator, who may edit it before calling
1666/// `coder.start`. Callable repeatedly on an open discussion.
1667///
1668/// Refuses while a turn is streaming: distilling then would run on the
1669/// operator's question with no answer beside it, and the model would happily
1670/// invent a confident intent from an unanswered question — which then feeds
1671/// `coder.start { discussion_id }` and contract derivation.
1672pub async fn promote(
1673    state: &Arc<ServerState>,
1674    discussion_id: &str,
1675    client_id: &str,
1676) -> Result<Value, String> {
1677    let entry = get_owned_discussion(state, discussion_id, client_id).await?;
1678    if entry.is_answering() {
1679        return Err(format!(
1680            "{discussion_id} is still answering — try again in a moment"
1681        ));
1682    }
1683    if entry.transcript_is_empty() {
1684        return Err(
1685            "this discussion has no turns yet — say what you are trying to do first".to_string(),
1686        );
1687    }
1688    let _start_guard = claim_coding_start(
1689        state,
1690        discussion_id,
1691        &entry.repo,
1692        super::rpc::coder_state_dir()?,
1693    )
1694    .await?;
1695    let context = coding_context(state, &entry).await?;
1696    let repo_context = format!(
1697        "{}\n{}\n{}",
1698        entry.repo_summary, entry.project_context, context
1699    );
1700    let (intent, constraints) =
1701        distill(&entry.generator, &entry.distill_transcript(), &repo_context).await?;
1702    entry.save_pending_task(Some((intent.clone(), constraints.clone())))?;
1703    *lock(&entry.last_promote) = Some((intent.clone(), constraints.clone()));
1704    entry.touch();
1705    Ok(json!({
1706        "discussion_id": entry.id,
1707        "proposed_intent": intent,
1708        "constraints": constraints,
1709    }))
1710}
1711
1712/// The distillation call. Generation is injected exactly the way
1713/// `derive_app_contract` injects it into `derive_contract`, so the prompt +
1714/// parse + bounded-retry shape is testable with a scripted model.
1715async fn distill(
1716    generator: &Arc<dyn TurnGenerator>,
1717    transcript: &str,
1718    repo_summary: &str,
1719) -> Result<(String, Vec<String>), String> {
1720    let mut last_err = String::from("no attempt was made");
1721    for _ in 0..PROMOTE_MAX_ATTEMPTS {
1722        let prompt = format!(
1723            "A developer has been discussing a change to a codebase. Distill the discussion \
1724             into ONE actionable coding intent plus the constraints they agreed on.\n\n\
1725             REPOSITORY\n{repo_summary}\n\n\
1726             DISCUSSION (most recent turns)\n{transcript}\n\n\
1727             Return ONLY a JSON object, no prose and no code fences:\n\
1728             {{\n  \"proposed_intent\": \"one paragraph, imperative, what to change and why\",\n  \
1729             \"constraints\": [\"a thing the change must not break or must respect\"]\n}}\n\n\
1730             Rules:\n\
1731             - `proposed_intent` is an INSTRUCTION, not a summary of the conversation. Never \
1732             quote the transcript back.\n\
1733             - Include only constraints actually agreed in the discussion. If none were, \
1734             return an empty array — do not invent any.\n"
1735        );
1736        let text = match generator
1737            .generate(car_inference::GenerateRequest {
1738                prompt,
1739                params: car_inference::GenerateParams {
1740                    temperature: 0.0,
1741                    max_tokens: 1024,
1742                    thinking: car_inference::tasks::generate::ThinkingMode::Off,
1743                    ..Default::default()
1744                },
1745                ..Default::default()
1746            })
1747            .await
1748        {
1749            Ok(r) => r.text,
1750            Err(e) => {
1751                last_err = format!("generation failed: {e}");
1752                continue;
1753            }
1754        };
1755        let value = match super::contract::extract_json_object(&text) {
1756            Ok(v) => v,
1757            Err(e) => {
1758                last_err = format!("output did not parse: {e}");
1759                continue;
1760            }
1761        };
1762        let intent = value
1763            .get("proposed_intent")
1764            .and_then(Value::as_str)
1765            .unwrap_or_default()
1766            .trim()
1767            .to_string();
1768        if intent.is_empty() {
1769            last_err = "the model returned no proposed_intent".to_string();
1770            continue;
1771        }
1772        let constraints: Vec<String> = value
1773            .get("constraints")
1774            .and_then(Value::as_array)
1775            .map(|a| {
1776                a.iter()
1777                    .filter_map(Value::as_str)
1778                    .map(str::trim)
1779                    .filter(|s| !s.is_empty())
1780                    .map(str::to_string)
1781                    .collect()
1782            })
1783            .unwrap_or_default();
1784        return Ok((intent, constraints));
1785    }
1786    Err(format!(
1787        "could not distill this discussion into an intent after {PROMOTE_MAX_ATTEMPTS} \
1788         attempts: {last_err}"
1789    ))
1790}
1791
1792/// Accepting a prepared task consumes its saved editor before task creation.
1793/// A failed start leaves the current client editor available for retry.
1794pub(super) async fn consume_prepared_task(
1795    state: &Arc<ServerState>,
1796    id: &str,
1797) -> Result<(), String> {
1798    get_discussion(state, id).await?.save_pending_task(None)
1799}
1800
1801/// Constraints to fold into `derive_contract` for a `coder.start
1802/// { discussion_id }`.
1803///
1804/// An unknown id is a hard error — a run that silently drops its grounding is
1805/// worse than one that refuses to start. A distillation failure also refuses
1806/// the start: the supplied intent need not repeat every preference agreed in
1807/// the conversation. The operator can retry without losing that grounding.
1808///
1809/// Refused while a turn is streaming, for the same reason `promote` is: the
1810/// distillation would run on the operator's question with no answer beside it,
1811/// and these constraints go straight into contract derivation.
1812pub async fn constraints_for_start(
1813    state: &Arc<ServerState>,
1814    discussion_id: &str,
1815) -> Result<Vec<String>, String> {
1816    let entry = get_discussion(state, discussion_id).await?;
1817    if entry.is_answering() {
1818        return Err(format!(
1819            "{discussion_id} is still answering — wait for `turn_complete` before starting a \
1820             run from it, or the constraints would be distilled from a question with no \
1821             answer beside it"
1822        ));
1823    }
1824    let cached = entry.constraints();
1825    if !cached.is_empty() {
1826        return Ok(cached);
1827    }
1828    if lock(&entry.last_promote).is_some() {
1829        // Promoted already, and it genuinely agreed no constraints.
1830        return Ok(Vec::new());
1831    }
1832    if entry.transcript_is_empty() {
1833        return Ok(Vec::new());
1834    }
1835    match distill(
1836        &entry.generator,
1837        &entry.distill_transcript(),
1838        &format!("{}\n{}", entry.repo_summary, entry.project_context),
1839    )
1840    .await
1841    {
1842        Ok((intent, constraints)) => {
1843            *lock(&entry.last_promote) = Some((intent, constraints.clone()));
1844            Ok(constraints)
1845        }
1846        Err(e) => {
1847            tracing::warn!(discussion_id, "discussion constraints unavailable: {e}");
1848            Err(format!(
1849                "Could not carry the conversation's requirements into this task: {e}. \
1850                 No task was started. Retry Build when model access is available."
1851            ))
1852        }
1853    }
1854}
1855
1856/// Close a discussion: cancel any in-flight turn, free its runtime, end its
1857/// drain.
1858pub async fn close(
1859    state: &Arc<ServerState>,
1860    discussion_id: &str,
1861    client_id: &str,
1862) -> Result<Value, String> {
1863    // Ownership first, and against the live registry: a foreign `close` must
1864    // not be able to cancel a turn its owner is watching.
1865    get_owned_discussion(state, discussion_id, client_id).await?;
1866    let entry = state.coder_discussions.lock().await.remove(discussion_id);
1867    let Some(entry) = entry else {
1868        return Err(format!("no open discussion '{discussion_id}'"));
1869    };
1870    // Actually stop the model: without this the turn keeps running against a
1871    // live provider, billing tokens to a conversation nobody can read. This
1872    // also latches the discussion closed, so a `send` parked mid-dispatch never
1873    // spawns its turn behind us.
1874    entry.cancel_turn();
1875    Ok(json!({ "ok": true }))
1876}
1877
1878/// Drop a disconnecting client's discussion state (called from
1879/// `remove_session`).
1880///
1881/// A discussion is owned by the connection that opened it (module docs), so
1882/// this closes it outright rather than only unsubscribing — otherwise every
1883/// closed board leaks an `AssistantService`, a `Runtime`, an open runtime
1884/// session, and an unbounded transcript for the daemon's lifetime. Other
1885/// clients' subscriptions to a surviving discussion are just detached.
1886pub async fn drop_subscriptions_for_client(state: &ServerState, client_id: &str) {
1887    let (owned, others): (Vec<_>, Vec<_>) = {
1888        let open = state.coder_discussions.lock().await;
1889        open.values()
1890            .cloned()
1891            .partition(|e| e.owner_client_id == client_id)
1892    };
1893    for entry in &others {
1894        let _ = entry.cmds.send(StreamCmd::Detach(client_id.to_string()));
1895    }
1896    if owned.is_empty() {
1897        return;
1898    }
1899    let mut open = state.coder_discussions.lock().await;
1900    for entry in owned {
1901        entry.cancel_turn();
1902        open.remove(&entry.id);
1903    }
1904}
1905
1906// ---------------------------------------------------------------------------
1907// JSON-RPC handlers (thin parsing wrappers)
1908// ---------------------------------------------------------------------------
1909
1910#[derive(Deserialize)]
1911struct StartParams {
1912    repo: PathBuf,
1913    #[serde(default)]
1914    resume_id: Option<String>,
1915    #[serde(default)]
1916    model: Option<String>,
1917}
1918
1919pub async fn handle_discuss_start(
1920    req: &JsonRpcMessage,
1921    state: &Arc<ServerState>,
1922    session: &Arc<ClientSession>,
1923) -> Result<Value, String> {
1924    let params: StartParams =
1925        serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
1926    let engine = crate::handler::get_inference_engine(state).clone();
1927    let generator: Arc<dyn TurnGenerator> = engine.clone();
1928    if let Some(model) = params
1929        .model
1930        .as_deref()
1931        .map(str::trim)
1932        .filter(|m| !m.is_empty() && *m != "auto")
1933    {
1934        if !engine.knows_model(model) {
1935            return Err(format!(
1936                "Unknown model '{model}'. Use `car models list` to choose an available model id."
1937            ));
1938        }
1939    }
1940    // The daemon is single-principal for operator connections; supervised
1941    // agents have a separately authenticated stable identity. Never derive
1942    // archive ownership from a caller-supplied parameter or a connection UUID.
1943    let principal = discussion_principal(session).await;
1944    open_discussion_with_model(
1945        state,
1946        &params.repo,
1947        &session.client_id,
1948        engine,
1949        generator,
1950        &principal,
1951        params.resume_id.as_deref(),
1952        params.model.as_deref(),
1953    )
1954    .await
1955}
1956
1957#[derive(Deserialize)]
1958struct SendParams {
1959    discussion_id: String,
1960    text: String,
1961}
1962
1963pub async fn handle_discuss_send(
1964    req: &JsonRpcMessage,
1965    state: &Arc<ServerState>,
1966    session: &Arc<ClientSession>,
1967) -> Result<Value, String> {
1968    let params: SendParams =
1969        serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
1970    send_message(
1971        state,
1972        &params.discussion_id,
1973        &session.client_id,
1974        &params.text,
1975    )
1976    .await
1977}
1978
1979#[derive(Deserialize)]
1980struct DiscussionIdParams {
1981    discussion_id: String,
1982}
1983
1984#[derive(Deserialize)]
1985struct SubscribeParams {
1986    discussion_id: String,
1987    #[serde(default)]
1988    from_seq: u64,
1989}
1990
1991pub async fn handle_discuss_subscribe(
1992    req: &JsonRpcMessage,
1993    state: &Arc<ServerState>,
1994    session: &Arc<ClientSession>,
1995) -> Result<Value, String> {
1996    let params: SubscribeParams =
1997        serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
1998    let entry = get_owned_discussion(state, &params.discussion_id, &session.client_id).await?;
1999    // A read counts as activity: a discussion an operator is actively watching
2000    // must not be eligible for the idle reaper.
2001    entry.touch();
2002    // Replay + register happen inside the drain task, which is the only owner
2003    // — so they are ordered against live emits without holding a lock across
2004    // any send.
2005    let (tx, rx) = oneshot::channel();
2006    entry
2007        .cmds
2008        .send(StreamCmd::Attach {
2009            client_id: session.client_id.clone(),
2010            channel: session.channel.clone(),
2011            from_seq: params.from_seq,
2012            replayed: tx,
2013        })
2014        .map_err(|_| format!("discussion '{}' is closing", params.discussion_id))?;
2015    let replayed = rx.await.unwrap_or(0);
2016    Ok(json!({ "events_replayed": replayed }))
2017}
2018
2019pub async fn handle_discuss_unsubscribe(
2020    req: &JsonRpcMessage,
2021    state: &Arc<ServerState>,
2022    session: &Arc<ClientSession>,
2023) -> Result<Value, String> {
2024    let params: DiscussionIdParams =
2025        serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
2026    if let Ok(entry) = get_discussion(state, &params.discussion_id).await {
2027        let _ = entry
2028            .cmds
2029            .send(StreamCmd::Detach(session.client_id.clone()));
2030    }
2031    Ok(json!({ "ok": true }))
2032}
2033
2034pub async fn handle_discuss_promote(
2035    req: &JsonRpcMessage,
2036    state: &Arc<ServerState>,
2037    session: &Arc<ClientSession>,
2038) -> Result<Value, String> {
2039    let params: DiscussionIdParams =
2040        serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
2041    // Model generation has a deep polling stack in debug builds. Poll it in
2042    // its own task, rather than underneath the large RPC dispatch future.
2043    // JoinSet owns cancellation: a deadline or disconnected caller dropping
2044    // this handler also aborts generation instead of leaving a detached bill.
2045    let state = state.clone();
2046    let client_id = session.client_id.clone();
2047    let mut generation = tokio::task::JoinSet::new();
2048    generation.spawn(async move { promote(&state, &params.discussion_id, &client_id).await });
2049    generation
2050        .join_next()
2051        .await
2052        .ok_or("conversation planning task did not start")?
2053        .map_err(|error| format!("conversation planning task failed: {error}"))?
2054}
2055
2056pub async fn handle_discuss_close(
2057    req: &JsonRpcMessage,
2058    state: &Arc<ServerState>,
2059    session: &Arc<ClientSession>,
2060) -> Result<Value, String> {
2061    let params: DiscussionIdParams =
2062        serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
2063    close(state, &params.discussion_id, &session.client_id).await
2064}
2065
2066async fn discussion_principal(session: &Arc<ClientSession>) -> String {
2067    match session.agent_id.lock().await.as_deref() {
2068        Some(id) => format!("agent:{id}"),
2069        None => "operator".to_string(),
2070    }
2071}
2072
2073/// `coder.discuss.list` — this connection's open discussions, plus the
2074/// authenticated principal's saved conversations that are not currently open.
2075///
2076/// Scoped to the caller, like every other `coder.discuss.*` method: a
2077/// discussion is owned by the connection that opened it, and listing another
2078/// connection's discussions would hand out ids the caller cannot use anyway.
2079pub async fn handle_discuss_list(
2080    state: &Arc<ServerState>,
2081    session: &Arc<ClientSession>,
2082) -> Result<Value, String> {
2083    let mut rows: Vec<Value> = state
2084        .coder_discussions
2085        .lock()
2086        .await
2087        .values()
2088        .filter(|e| e.owner_client_id == session.client_id)
2089        .map(|e| e.summary_row())
2090        .collect();
2091    rows.sort_by_key(|v| std::cmp::Reverse(v["created_at"].as_u64().unwrap_or(0)));
2092    let principal = discussion_principal(session).await;
2093    let records = DiscussionRecord::list(&state.journal_dir, &principal)?;
2094    let live = state.coder_discussions.lock().await;
2095    let saved: Vec<Value> = records
2096        .into_iter()
2097        .filter(|record| !live.contains_key(&record.id))
2098        .map(|record| {
2099            json!({
2100                "discussion_id": record.id,
2101                "repo": record.repo,
2102                "created_at": record.created_at,
2103            })
2104        })
2105        .collect();
2106    Ok(json!({ "discussions": rows, "saved": saved }))
2107}
2108
2109#[cfg(test)]
2110mod tests {
2111    use super::*;
2112    use async_trait::async_trait;
2113    use car_inference::{GenerateRequest, InferenceResult};
2114    use std::sync::atomic::AtomicUsize;
2115
2116    fn turn(text: &str, tool_calls: Value) -> InferenceResult {
2117        serde_json::from_value(json!({
2118            "text": text, "tool_calls": tool_calls,
2119            "trace_id": "t", "model_used": "scripted", "latency_ms": 0,
2120        }))
2121        .expect("scripted InferenceResult shape")
2122    }
2123
2124    struct Script {
2125        turns: Vec<InferenceResult>,
2126        cursor: AtomicUsize,
2127    }
2128
2129    #[async_trait]
2130    impl TurnGenerator for Script {
2131        async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
2132            let i = self.cursor.fetch_add(1, Ordering::SeqCst);
2133            self.turns
2134                .get(i)
2135                .cloned()
2136                .ok_or_else(|| "script exhausted".to_string())
2137        }
2138    }
2139
2140    /// A generator that blocks until released — lets a test observe a turn
2141    /// while it is genuinely in flight.
2142    ///
2143    /// Released with `notify_one`, never `notify_waiters`: the turn is spawned,
2144    /// so the test can reach the release before the task has registered as a
2145    /// waiter, and `notify_waiters` wakes only waiters that already exist.
2146    /// `notify_one` stores a permit, so the ordering does not matter.
2147    struct Blocking {
2148        gate: Arc<tokio::sync::Notify>,
2149    }
2150
2151    #[async_trait]
2152    impl TurnGenerator for Blocking {
2153        async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
2154            self.gate.notified().await;
2155            Ok(turn("done at last", json!([])))
2156        }
2157    }
2158
2159    /// Counts invocations — for asserting a turn NEVER reached the model.
2160    struct Counting {
2161        calls: Arc<AtomicUsize>,
2162    }
2163
2164    #[async_trait]
2165    impl TurnGenerator for Counting {
2166        async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
2167            self.calls.fetch_add(1, Ordering::SeqCst);
2168            Ok(turn("counted", json!([])))
2169        }
2170    }
2171
2172    fn init_repo(dir: &Path) {
2173        for args in [
2174            vec!["init", "-q", "-b", "main"],
2175            vec![
2176                "-c",
2177                "user.name=t",
2178                "-c",
2179                "user.email=t@t",
2180                "commit",
2181                "-q",
2182                "--allow-empty",
2183                "-m",
2184                "init",
2185            ],
2186        ] {
2187            let out = std::process::Command::new("git")
2188                .arg("-C")
2189                .arg(dir)
2190                .args(&args)
2191                .output()
2192                .unwrap();
2193            assert!(
2194                out.status.success(),
2195                "{}",
2196                String::from_utf8_lossy(&out.stderr)
2197            );
2198        }
2199    }
2200
2201    fn engine(root: &Path) -> Arc<car_inference::InferenceEngine> {
2202        let mut cfg = car_inference::InferenceConfig::default();
2203        cfg.models_dir = root.join("models");
2204        Arc::new(car_inference::InferenceEngine::new(cfg))
2205    }
2206
2207    /// A standalone daemon state plus the journal dir it writes to — the
2208    /// caller keeps the `TempDir` alive for the length of the test.
2209    fn state() -> (Arc<ServerState>, tempfile::TempDir) {
2210        let journal = tempfile::tempdir().unwrap();
2211        let state = Arc::new(ServerState::standalone(journal.path().to_path_buf()));
2212        (state, journal)
2213    }
2214
2215    async fn start(
2216        state: &Arc<ServerState>,
2217        repo: &Path,
2218        generator: Arc<dyn TurnGenerator>,
2219    ) -> String {
2220        let started = start_discussion(state, repo, "owner-1", engine(repo), generator)
2221            .await
2222            .unwrap();
2223        started["discussion_id"].as_str().unwrap().to_string()
2224    }
2225
2226    /// A `ClientSession` over a drain sink — enough for the handlers that need
2227    /// a connection identity, without a tungstenite handshake.
2228    async fn client(state: &Arc<ServerState>, id: &str) -> Arc<ClientSession> {
2229        state
2230            .create_session(id, Arc::new(crate::session::WsChannel::test_stub()))
2231            .await
2232            .unwrap()
2233    }
2234
2235    /// A WS sink that keeps every frame instead of writing it, so a test can
2236    /// read exactly what a subscriber's lane delivered. `test_stub` drains to
2237    /// nowhere, which is enough for membership checks but says nothing about
2238    /// what arrived.
2239    struct CaptureSink(Arc<StdMutex<Vec<String>>>);
2240
2241    impl futures::Sink<tokio_tungstenite::tungstenite::Message> for CaptureSink {
2242        type Error = tokio_tungstenite::tungstenite::Error;
2243
2244        fn poll_ready(
2245            self: std::pin::Pin<&mut Self>,
2246            _: &mut std::task::Context<'_>,
2247        ) -> std::task::Poll<Result<(), Self::Error>> {
2248            std::task::Poll::Ready(Ok(()))
2249        }
2250
2251        fn start_send(
2252            self: std::pin::Pin<&mut Self>,
2253            item: tokio_tungstenite::tungstenite::Message,
2254        ) -> Result<(), Self::Error> {
2255            if let tokio_tungstenite::tungstenite::Message::Text(text) = item {
2256                lock(&self.0).push(text.to_string());
2257            }
2258            Ok(())
2259        }
2260
2261        fn poll_flush(
2262            self: std::pin::Pin<&mut Self>,
2263            _: &mut std::task::Context<'_>,
2264        ) -> std::task::Poll<Result<(), Self::Error>> {
2265            std::task::Poll::Ready(Ok(()))
2266        }
2267
2268        fn poll_close(
2269            self: std::pin::Pin<&mut Self>,
2270            _: &mut std::task::Context<'_>,
2271        ) -> std::task::Poll<Result<(), Self::Error>> {
2272            std::task::Poll::Ready(Ok(()))
2273        }
2274    }
2275
2276    /// A real `WsChannel` over [`CaptureSink`], plus the frames it collected.
2277    /// Locking its `write` half is a half-open peer: writes stop completing and
2278    /// never fail, exactly what wedges a subscriber's lane.
2279    fn capturing_channel() -> (Arc<WsChannel>, Arc<StdMutex<Vec<String>>>) {
2280        let frames = Arc::new(StdMutex::new(Vec::new()));
2281        let sink: crate::session::WsSink = Box::pin(CaptureSink(frames.clone()));
2282        let channel = Arc::new(WsChannel {
2283            write: tokio::sync::Mutex::new(sink),
2284            pending: tokio::sync::Mutex::new(HashMap::new()),
2285            active_actions: tokio::sync::Mutex::new(HashMap::new()),
2286            next_id: AtomicU64::new(0),
2287        });
2288        (channel, frames)
2289    }
2290
2291    fn rpc_req(params: Value) -> JsonRpcMessage {
2292        serde_json::from_value(json!({ "jsonrpc": "2.0", "id": 1, "params": params }))
2293            .expect("JsonRpcMessage shape")
2294    }
2295
2296    /// The `seq` of every `coder.discuss.event` frame a lane delivered.
2297    fn delivered_seqs(frames: &Arc<StdMutex<Vec<String>>>) -> Vec<u64> {
2298        lock(frames)
2299            .iter()
2300            .map(|f| serde_json::from_str::<Value>(f).expect("a lane frame must be JSON"))
2301            .inspect(|v| assert_eq!(v["method"], "coder.discuss.event", "unexpected frame: {v}"))
2302            .map(|v| {
2303                v["params"]["seq"]
2304                    .as_u64()
2305                    .expect("every event carries a seq")
2306            })
2307            .collect()
2308    }
2309
2310    async fn wait_for_turn_complete(entry: &Arc<DiscussionEntry>) {
2311        for _ in 0..400 {
2312            {
2313                let events = entry.events.lock().await;
2314                if events
2315                    .iter()
2316                    .any(|e| matches!(e.kind, DiscussEventKind::TurnComplete {}))
2317                {
2318                    return;
2319                }
2320            }
2321            tokio::time::sleep(std::time::Duration::from_millis(25)).await;
2322        }
2323        panic!("discussion turn never completed");
2324    }
2325
2326    async fn wait_for_idle(entry: &Arc<DiscussionEntry>) {
2327        tokio::time::timeout(std::time::Duration::from_secs(10), async {
2328            while entry.is_answering() {
2329                tokio::task::yield_now().await;
2330            }
2331        })
2332        .await
2333        .expect("discussion turn did not release its guard");
2334    }
2335
2336    #[tokio::test]
2337    async fn conversation_recovers_after_restart_with_same_identity_and_full_model_history() {
2338        let repo = tempfile::tempdir().unwrap();
2339        init_repo(repo.path());
2340        std::fs::write(repo.path().join("AGENTS.md"), "Initial repository rule.").unwrap();
2341        std::fs::write(repo.path().join("CLAUDE.md"), "Preserve exported names.").unwrap();
2342        let (state, journal) = state();
2343        let script = || {
2344            Arc::new(Script {
2345                turns: vec![turn("Remember the export regression.", json!([]))],
2346                cursor: AtomicUsize::new(0),
2347            }) as Arc<dyn TurnGenerator>
2348        };
2349        let started = open_discussion(
2350            &state,
2351            repo.path(),
2352            "connection-1",
2353            engine(repo.path()),
2354            script(),
2355            "operator",
2356            None,
2357        )
2358        .await
2359        .unwrap();
2360        let id = started["discussion_id"].as_str().unwrap().to_string();
2361        let entry = get_discussion(&state, &id).await.unwrap();
2362        let checkpoint = entry
2363            .durability
2364            .load_checkpoint(&id)
2365            .await
2366            .unwrap()
2367            .unwrap();
2368        let system = serde_json::to_string(&checkpoint.messages[0]).unwrap();
2369        assert!(system.contains("Initial repository rule."));
2370        assert!(system.contains("Preserve exported names."));
2371        assert!(system.contains("does not expand the read-only permissions"));
2372        send_message(
2373            &state,
2374            &id,
2375            "connection-1",
2376            "Investigate the export regression.",
2377        )
2378        .await
2379        .unwrap();
2380        wait_for_turn_complete(&entry).await;
2381        wait_for_idle(&entry).await;
2382        let err = open_discussion(
2383            &state,
2384            repo.path(),
2385            "connection-2",
2386            engine(repo.path()),
2387            script(),
2388            "operator",
2389            Some(&id),
2390        )
2391        .await
2392        .unwrap_err();
2393        assert!(err.contains("already open"));
2394        close(&state, &id, "connection-1").await.unwrap();
2395        assert!(entry
2396            .durability
2397            .checkpoint(&id, &[], "late writer", None)
2398            .await
2399            .is_err());
2400        drop(entry);
2401        drop(state);
2402
2403        std::fs::write(repo.path().join("AGENTS.md"), "Updated repository rule.").unwrap();
2404        let restarted = Arc::new(ServerState::standalone(journal.path().to_path_buf()));
2405        assert!(open_discussion(
2406            &restarted,
2407            repo.path(),
2408            "foreign",
2409            engine(repo.path()),
2410            script(),
2411            "agent:foreign",
2412            Some(&id)
2413        )
2414        .await
2415        .is_err());
2416        let resumed = open_discussion(
2417            &restarted,
2418            repo.path(),
2419            "connection-2",
2420            engine(repo.path()),
2421            script(),
2422            "operator",
2423            Some(&id),
2424        )
2425        .await
2426        .unwrap();
2427        assert_eq!(resumed["discussion_id"], id);
2428        assert_eq!(resumed["resumed"], true);
2429        let entry = get_discussion(&restarted, &id).await.unwrap();
2430        assert_eq!(entry.turns.load(Ordering::SeqCst), 1);
2431        assert!(lock(&entry.transcript)
2432            .iter()
2433            .any(|(_, text)| text == "Remember the export regression."));
2434        send_message(
2435            &restarted,
2436            &id,
2437            "connection-2",
2438            "Now explain the next step.",
2439        )
2440        .await
2441        .unwrap();
2442        wait_for_idle(&entry).await;
2443        let checkpoint = entry
2444            .durability
2445            .load_checkpoint(&id)
2446            .await
2447            .unwrap()
2448            .unwrap();
2449        let history = serde_json::to_string(&checkpoint.messages).unwrap();
2450        assert!(history.contains("Updated repository rule."));
2451        assert!(!history.contains("Initial repository rule."));
2452        assert!(history.contains("Preserve exported names."));
2453        assert!(history.contains("Investigate the export regression."));
2454        assert!(history.contains("Remember the export regression."));
2455        assert!(history.contains("Now explain the next step."));
2456        assert!(get_owned_discussion(&restarted, &id, "connection-1")
2457            .await
2458            .is_err());
2459        close(&restarted, &id, "connection-2").await.unwrap();
2460    }
2461
2462    #[tokio::test]
2463    async fn discuss_start_rejects_a_non_git_directory() {
2464        let dir = tempfile::tempdir().unwrap();
2465        let (state, _journal) = state();
2466        let script: Arc<dyn TurnGenerator> = Arc::new(Script {
2467            turns: vec![],
2468            cursor: AtomicUsize::new(0),
2469        });
2470        let err = start_discussion(&state, dir.path(), "owner-1", engine(dir.path()), script)
2471            .await
2472            .unwrap_err();
2473        assert!(
2474            err.contains("is not a git repository")
2475                && err.contains("discuss needs a repo to ground itself in"),
2476            "operator-readable non-repo error, got: {err}"
2477        );
2478    }
2479
2480    #[tokio::test]
2481    async fn conversation_advertises_relevant_tools_without_mutations() {
2482        struct Capture(Arc<StdMutex<Vec<Value>>>);
2483        #[async_trait]
2484        impl TurnGenerator for Capture {
2485            async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
2486                *lock(&self.0) = req.tools.unwrap_or_default();
2487                Ok(turn("Here is how the repository is organized.", json!([])))
2488            }
2489        }
2490        let repo = tempfile::tempdir().unwrap();
2491        init_repo(repo.path());
2492        let (state, _journal) = state();
2493        let tools = Arc::new(StdMutex::new(Vec::new()));
2494        let id = start(&state, repo.path(), Arc::new(Capture(tools.clone()))).await;
2495        send_message(&state, &id, "owner-1", "Explain the repository")
2496            .await
2497            .unwrap();
2498        let entry = get_discussion(&state, &id).await.unwrap();
2499        wait_for_idle(&entry).await;
2500        let captured = lock(&tools);
2501        let names: Vec<_> = captured
2502            .iter()
2503            .filter_map(|def| def["name"].as_str())
2504            .collect();
2505        for required in [
2506            "read_file",
2507            "list_dir",
2508            "find_files",
2509            "grep_files",
2510            "prepare_coding_task",
2511            "web_search",
2512            "http_request",
2513        ] {
2514            assert!(names.contains(&required), "missing {required}: {names:?}");
2515        }
2516        assert!(
2517            names.len() <= 8,
2518            "unrelated tools leaked into coding conversation: {names:?}"
2519        );
2520        assert!(!names.contains(&"write_file"));
2521        assert!(!names.contains(&"shell"));
2522        assert!(
2523            lock(&entry.last_promote).is_none(),
2524            "a question must not automatically prepare a task"
2525        );
2526        eprintln!(
2527            "conversation tool payload: {} tools, {} JSON bytes",
2528            names.len(),
2529            serde_json::to_vec(&*captured).unwrap().len()
2530        );
2531    }
2532
2533    #[tokio::test]
2534    async fn project_policy_can_refuse_task_preparation() {
2535        let repo = tempfile::tempdir().unwrap();
2536        init_repo(repo.path());
2537        let policies = repo.path().join(".car/policies");
2538        std::fs::create_dir_all(&policies).unwrap();
2539        std::fs::write(
2540            policies.join("rules.toml"),
2541            "deny_tool = [\"prepare_coding_task\"]\n",
2542        )
2543        .unwrap();
2544        let (state, _journal) = state();
2545        let script: Arc<dyn TurnGenerator> = Arc::new(Script {
2546            turns: vec![
2547                turn(
2548                    "",
2549                    json!([{"id":"prepare-denied", "name":"prepare_coding_task",
2550                    "arguments":{"intent":"Fix parser", "constraints":[]}}]),
2551                ),
2552                turn(
2553                    "Task preparation is unavailable under repository policy.",
2554                    json!([]),
2555                ),
2556            ],
2557            cursor: AtomicUsize::new(0),
2558        });
2559        let id = start(&state, repo.path(), script).await;
2560        send_message(&state, &id, "owner-1", "Fix parser")
2561            .await
2562            .unwrap();
2563        let entry = get_discussion(&state, &id).await.unwrap();
2564        wait_for_turn_complete(&entry).await;
2565        assert!(lock(&entry.last_promote).is_none());
2566        assert!(!entry
2567            .events
2568            .lock()
2569            .await
2570            .iter()
2571            .any(|event| matches!(event.kind, DiscussEventKind::TaskPrepared { .. })));
2572    }
2573
2574    #[tokio::test]
2575    async fn implementation_request_prepares_task_without_starting_execution() {
2576        let repo = tempfile::tempdir().unwrap();
2577        init_repo(repo.path());
2578        let (state, _journal) = state();
2579        let script: Arc<dyn TurnGenerator> = Arc::new(Script {
2580            turns: vec![
2581                turn(
2582                    "",
2583                    json!([{"id":"prepare-1", "name":"prepare_coding_task",
2584                    "arguments":{"intent":"Fix the parser", "constraints":["Preserve public APIs"]}}]),
2585                ),
2586                turn("The parser task is ready for review.", json!([])),
2587            ],
2588            cursor: AtomicUsize::new(0),
2589        });
2590        let id = start(&state, repo.path(), script).await;
2591        send_message(
2592            &state,
2593            &id,
2594            "owner-1",
2595            "Please fix the parser and preserve public APIs.",
2596        )
2597        .await
2598        .unwrap();
2599        let entry = get_discussion(&state, &id).await.unwrap();
2600        wait_for_turn_complete(&entry).await;
2601        assert_eq!(entry.constraints(), vec!["Preserve public APIs"]);
2602        let events = entry.events.lock().await;
2603        assert!(events.iter().any(|event| matches!(&event.kind,
2604            DiscussEventKind::TaskPrepared { proposed_intent, constraints }
2605            if proposed_intent == "Fix the parser" && constraints == &["Preserve public APIs"])));
2606        assert!(
2607            state.coder_sessions.lock().await.is_empty(),
2608            "preparing must not create a coding session"
2609        );
2610        assert!(!repo.path().join("parser.rs").exists());
2611        drop(events);
2612        wait_for_idle(&entry).await;
2613        let journal = state.journal_dir.clone();
2614        close(&state, &id, "owner-1").await.unwrap();
2615        drop(entry);
2616        drop(state);
2617        let restarted = Arc::new(ServerState::standalone(journal));
2618        let no_turns = || -> Arc<dyn TurnGenerator> {
2619            Arc::new(Script {
2620                turns: vec![],
2621                cursor: AtomicUsize::new(0),
2622            })
2623        };
2624        open_discussion(
2625            &restarted,
2626            repo.path(),
2627            "owner-2",
2628            engine(repo.path()),
2629            no_turns(),
2630            "owner-1",
2631            Some(&id),
2632        )
2633        .await
2634        .unwrap();
2635        let resumed = get_discussion(&restarted, &id).await.unwrap();
2636        assert_eq!(resumed.constraints(), vec!["Preserve public APIs"]);
2637        assert!(
2638            resumed
2639                .events
2640                .lock()
2641                .await
2642                .iter()
2643                .any(|event| matches!(&event.kind,
2644            DiscussEventKind::ToolCall { tool, params_preview }
2645            if tool == "prepare_coding_task" && params_preview.contains("Fix the parser"))),
2646            "resume must show the recorded tool call without executing it again"
2647        );
2648        assert!(restarted.coder_sessions.lock().await.is_empty());
2649        assert!(resumed.events.lock().await.iter().any(|event| matches!(&event.kind,
2650            DiscussEventKind::TaskPrepared { proposed_intent, .. } if proposed_intent == "Fix the parser")));
2651        consume_prepared_task(&restarted, &id).await.unwrap();
2652        close(&restarted, &id, "owner-2").await.unwrap();
2653        open_discussion(
2654            &restarted,
2655            repo.path(),
2656            "owner-3",
2657            engine(repo.path()),
2658            no_turns(),
2659            "owner-1",
2660            Some(&id),
2661        )
2662        .await
2663        .unwrap();
2664        let consumed = get_discussion(&restarted, &id).await.unwrap();
2665        assert!(!consumed
2666            .events
2667            .lock()
2668            .await
2669            .iter()
2670            .any(|event| matches!(event.kind, DiscussEventKind::TaskPrepared { .. })));
2671    }
2672
2673    /// The load-bearing property: a discussion NEVER writes in the target repo.
2674    #[tokio::test]
2675    async fn a_discussion_writes_nothing_in_the_repo() {
2676        let repo = tempfile::tempdir().unwrap();
2677        init_repo(repo.path());
2678        std::fs::write(repo.path().join("keep.txt"), "original").unwrap();
2679        let (state, _journal) = state();
2680
2681        let script: Arc<dyn TurnGenerator> = Arc::new(Script {
2682            turns: vec![
2683                turn(
2684                    "",
2685                    json!([{
2686                        "id": "c1", "name": "write_file",
2687                        "arguments": {"path": "sneaky.txt", "content": "written by a discussion"}
2688                    }]),
2689                ),
2690                turn(
2691                    "",
2692                    json!([{
2693                        "id": "c2", "name": "shell",
2694                        "arguments": {"command": "printf x > shelled.txt"}
2695                    }]),
2696                ),
2697                turn(
2698                    "I cannot edit from a discussion; here is what I would change.",
2699                    json!([]),
2700                ),
2701            ],
2702            cursor: AtomicUsize::new(0),
2703        });
2704
2705        let id = start(&state, repo.path(), script).await;
2706        assert!(id.starts_with("disc-"));
2707        send_message(
2708            &state,
2709            &id,
2710            "owner-1",
2711            "can you just make the change for me?",
2712        )
2713        .await
2714        .unwrap();
2715        let entry = get_discussion(&state, &id).await.unwrap();
2716        wait_for_turn_complete(&entry).await;
2717
2718        assert!(
2719            !repo.path().join("sneaky.txt").exists(),
2720            "a discussion must not create files in the repo"
2721        );
2722        assert!(
2723            !repo.path().join("shelled.txt").exists(),
2724            "a discussion must not run shell commands that write"
2725        );
2726        assert_eq!(
2727            std::fs::read_to_string(repo.path().join("keep.txt")).unwrap(),
2728            "original"
2729        );
2730
2731        let events = entry.events.lock().await;
2732        assert!(
2733            events.iter().any(|e| matches!(
2734                &e.kind,
2735                DiscussEventKind::ToolResult { ok, preview, .. }
2736                    if !ok && preview.contains("read-only")
2737            )),
2738            "the denial must surface as a tool_result"
2739        );
2740        drop(events);
2741        wait_for_idle(&entry).await;
2742        let checkpoint = entry
2743            .durability
2744            .load_checkpoint(&id)
2745            .await
2746            .unwrap()
2747            .unwrap();
2748        let refusals: Vec<_> = checkpoint
2749            .messages
2750            .iter()
2751            .filter_map(|message| {
2752                if let car_inference::Message::ToolResult { content, .. } = message {
2753                    Some(content.as_str())
2754                } else {
2755                    None
2756                }
2757            })
2758            .collect();
2759        assert!(refusals
2760            .iter()
2761            .any(|text| text.contains("prepare_coding_task")
2762                && text.contains("not a file-permission problem")));
2763        assert!(!refusals
2764            .iter()
2765            .any(|text| text.contains("declined by user")));
2766    }
2767
2768    /// The other half of the boundary: a discussion cannot READ outside its
2769    /// repo. Mutation-gating alone left the read tools pointed at the whole
2770    /// filesystem, and their output streams to every subscriber.
2771    #[tokio::test]
2772    async fn a_discussion_cannot_read_outside_the_repo() {
2773        let outside = tempfile::tempdir().unwrap();
2774        let secret_path = outside.path().join("credentials.txt");
2775        std::fs::write(&secret_path, "sk-ant-SUPERSECRETVALUE").unwrap();
2776
2777        let repo = tempfile::tempdir().unwrap();
2778        init_repo(repo.path());
2779        let (state, _journal) = state();
2780
2781        let script: Arc<dyn TurnGenerator> = Arc::new(Script {
2782            turns: vec![
2783                // Absolute path outside the repo — the exfiltration attempt.
2784                turn(
2785                    "",
2786                    json!([{
2787                        "id": "c1", "name": "read_file",
2788                        "arguments": {"path": secret_path.to_string_lossy()}
2789                    }]),
2790                ),
2791                // ...and the directory-scanning variant.
2792                turn(
2793                    "",
2794                    json!([{
2795                        "id": "c2", "name": "grep_files",
2796                        "arguments": {"path": outside.path().to_string_lossy(), "pattern": "sk-ant-"}
2797                    }]),
2798                ),
2799                turn("I can only read inside this repository.", json!([])),
2800            ],
2801            cursor: AtomicUsize::new(0),
2802        });
2803
2804        let id = start(&state, repo.path(), script).await;
2805        send_message(
2806            &state,
2807            &id,
2808            "owner-1",
2809            "what credentials does this project use?",
2810        )
2811        .await
2812        .unwrap();
2813        let entry = get_discussion(&state, &id).await.unwrap();
2814        wait_for_turn_complete(&entry).await;
2815
2816        let events = entry.events.lock().await;
2817        let stream = serde_json::to_string(&*events).unwrap();
2818        assert!(
2819            !stream.contains("SUPERSECRETVALUE"),
2820            "a discussion must never stream content from outside its repo: {stream}"
2821        );
2822    }
2823
2824    #[tokio::test]
2825    async fn selected_model_survives_reopen_and_auto_clears_it() {
2826        struct Recording {
2827            requests: Arc<StdMutex<Vec<(Option<String>, bool)>>>,
2828        }
2829        #[async_trait]
2830        impl TurnGenerator for Recording {
2831            async fn generate(&self, req: GenerateRequest) -> Result<InferenceResult, String> {
2832                lock(&self.requests).push((req.model, req.params.strict_model));
2833                Ok(turn("A grounded reply.", json!([])))
2834            }
2835        }
2836        let repo = tempfile::tempdir().unwrap();
2837        init_repo(repo.path());
2838        let (state, journal) = state();
2839        let mut cfg = car_inference::InferenceConfig::default();
2840        cfg.models_dir = journal.path().join("models");
2841        let engine = Arc::new(car_inference::InferenceEngine::new(cfg));
2842        let requests = Arc::new(StdMutex::new(Vec::new()));
2843        let generator: Arc<dyn TurnGenerator> = Arc::new(Recording {
2844            requests: requests.clone(),
2845        });
2846        let rejected = open_discussion_with_model(
2847            &state,
2848            repo.path(),
2849            "owner",
2850            engine.clone(),
2851            generator.clone(),
2852            "operator",
2853            None,
2854            Some("qwen/qwen3-embedding-0.6b:q8_0"),
2855        )
2856        .await
2857        .unwrap_err();
2858        assert!(
2859            rejected.contains("cannot call repository tools"),
2860            "{rejected}"
2861        );
2862        assert!(requests.lock().unwrap().is_empty());
2863        let first = open_discussion_with_model(
2864            &state,
2865            repo.path(),
2866            "owner",
2867            engine.clone(),
2868            generator.clone(),
2869            "operator",
2870            None,
2871            Some("anthropic/claude-opus-4-6:latest"),
2872        )
2873        .await
2874        .unwrap();
2875        let id = first["discussion_id"].as_str().unwrap();
2876        assert_eq!(first["model"], "anthropic/claude-opus-4-6:latest");
2877        for selection in [None, Some("auto")] {
2878            send_message(&state, id, "owner", "What is here?")
2879                .await
2880                .unwrap();
2881            let entry = get_discussion(&state, id).await.unwrap();
2882            wait_for_idle(&entry).await;
2883            close(&state, id, "owner").await.unwrap();
2884            let reopened = open_discussion_with_model(
2885                &state,
2886                repo.path(),
2887                "owner",
2888                engine.clone(),
2889                generator.clone(),
2890                "operator",
2891                Some(id),
2892                selection,
2893            )
2894            .await
2895            .unwrap();
2896            if selection.is_none() {
2897                assert_eq!(reopened["model"], "anthropic/claude-opus-4-6:latest");
2898            } else {
2899                assert!(reopened["model"].is_null());
2900            }
2901        }
2902        send_message(&state, id, "owner", "Continue.")
2903            .await
2904            .unwrap();
2905        wait_for_idle(&get_discussion(&state, id).await.unwrap()).await;
2906        close(&state, id, "owner").await.unwrap();
2907        let captured = lock(&requests);
2908        assert_eq!(
2909            *captured,
2910            vec![
2911                (Some("anthropic/claude-opus-4-6:latest".into()), true),
2912                (Some("anthropic/claude-opus-4-6:latest".into()), true),
2913                (None, false)
2914            ]
2915        );
2916        assert!(DiscussionRecord::load(&state.journal_dir, id, "operator")
2917            .unwrap()
2918            .model
2919            .is_none());
2920    }
2921
2922    #[tokio::test]
2923    async fn starting_refuses_to_drop_requirements_when_distillation_fails() {
2924        let repo = tempfile::tempdir().unwrap();
2925        init_repo(repo.path());
2926        let (state, _journal) = state();
2927        let script: Arc<dyn TurnGenerator> = Arc::new(Script {
2928            // The reply succeeds; all later generation attempts fail.
2929            turns: vec![turn("I will preserve the public API.", json!([]))],
2930            cursor: AtomicUsize::new(0),
2931        });
2932        let id = start(&state, repo.path(), script).await;
2933        send_message(&state, &id, "owner-1", "Preserve the public API.")
2934            .await
2935            .unwrap();
2936        let entry = get_discussion(&state, &id).await.unwrap();
2937        wait_for_idle(&entry).await;
2938
2939        let error = constraints_for_start(&state, &id).await.unwrap_err();
2940        assert!(error.contains("No task was started"), "{error}");
2941        assert!(error.contains("Retry Build"), "{error}");
2942        assert!(lock(&entry.last_promote).is_none());
2943        assert!(!entry.transcript_is_empty());
2944        assert!(state.coder_sessions.lock().await.is_empty());
2945        close(&state, &id, "owner-1").await.unwrap();
2946    }
2947
2948    #[tokio::test]
2949    async fn promote_distills_an_intent_and_starts_nothing() {
2950        let repo = tempfile::tempdir().unwrap();
2951        init_repo(repo.path());
2952        let (state, _journal) = state();
2953
2954        let script: Arc<dyn TurnGenerator> = Arc::new(Script {
2955            turns: vec![
2956                turn("The Windows path is the risky one.", json!([])),
2957                turn(
2958                    r#"{"proposed_intent":"Make the config loader resolve paths on Windows.",
2959                        "constraints":["do not change the POSIX behavior"]}"#,
2960                    json!([]),
2961                ),
2962                turn("Understood, preserve both platforms.", json!([])),
2963            ],
2964            cursor: AtomicUsize::new(0),
2965        });
2966
2967        let id = start(&state, repo.path(), script).await;
2968        send_message(
2969            &state,
2970            &id,
2971            "owner-1",
2972            "what is fragile about the config loader?",
2973        )
2974        .await
2975        .unwrap();
2976        let entry = get_discussion(&state, &id).await.unwrap();
2977        wait_for_turn_complete(&entry).await;
2978
2979        wait_for_idle(&entry).await;
2980        let promoted = promote(&state, &id, "owner-1").await.unwrap();
2981        assert_eq!(
2982            promoted["proposed_intent"],
2983            "Make the config loader resolve paths on Windows."
2984        );
2985        assert_eq!(
2986            promoted["constraints"],
2987            json!(["do not change the POSIX behavior"])
2988        );
2989        assert!(state.coder_sessions.lock().await.is_empty());
2990        assert_eq!(
2991            constraints_for_start(&state, &id).await.unwrap(),
2992            vec!["do not change the POSIX behavior".to_string()]
2993        );
2994        send_message(
2995            &state,
2996            &id,
2997            "owner-1",
2998            "Also preserve Windows compatibility.",
2999        )
3000        .await
3001        .unwrap();
3002        assert!(
3003            lock(&entry.last_promote).is_none(),
3004            "a new turn must invalidate the old plan constraints"
3005        );
3006        wait_for_idle(&entry).await;
3007        close(&state, &id, "owner-1").await.unwrap();
3008    }
3009
3010    /// A discussion turn refused on the Parslee account must still END.
3011    ///
3012    /// `coder.discuss` runs the same `AssistantService` as chat, so it sees the
3013    /// same terminal `auth_required` frame — and before this arm existed the
3014    /// frame fell through to `_ => {}`: no error, no remedy, and no
3015    /// `TurnComplete`. `docs/websocket-protocol.md` tells a client to wait for
3016    /// `TurnComplete` before sending again, so the client was left holding a
3017    /// turn that had already finished. The second `send` at the end is the
3018    /// point: it proves the discussion is usable afterwards, not just that an
3019    /// event was emitted.
3020    #[tokio::test]
3021    async fn an_account_refusal_ends_the_discussion_turn_with_its_remedy() {
3022        struct SignedOut;
3023        #[async_trait]
3024        impl TurnGenerator for SignedOut {
3025            async fn generate(&self, _req: GenerateRequest) -> Result<InferenceResult, String> {
3026                panic!("the assistant loop must generate through the typed seam")
3027            }
3028
3029            async fn generate_assistant(
3030                &self,
3031                _req: GenerateRequest,
3032            ) -> Result<InferenceResult, crate::coder::native_loop::AssistantGenerateError>
3033            {
3034                Err(crate::coder::native_loop::AssistantGenerateError::from(
3035                    car_inference::InferenceError::CredentialUnavailable {
3036                        provider: "parslee".into(),
3037                        model: "parslee/advisor".into(),
3038                        reason: car_inference::CredentialFailure::SignedOut,
3039                        detail: "no account is signed in. Run `car auth login`".into(),
3040                    },
3041                ))
3042            }
3043        }
3044
3045        let repo = tempfile::tempdir().unwrap();
3046        init_repo(repo.path());
3047        let (state, _journal) = state();
3048        let generator: Arc<dyn TurnGenerator> = Arc::new(SignedOut);
3049        let id = start(&state, repo.path(), generator).await;
3050
3051        send_message(&state, &id, "owner-1", "what is fragile here?")
3052            .await
3053            .unwrap();
3054        let entry = get_discussion(&state, &id).await.unwrap();
3055        wait_for_turn_complete(&entry).await;
3056
3057        let kinds: Vec<DiscussEventKind> = {
3058            let events = entry.events.lock().await;
3059            events.iter().map(|e| e.kind.clone()).collect()
3060        };
3061        let message = kinds
3062            .iter()
3063            .find_map(|k| match k {
3064                DiscussEventKind::Error { message } => Some(message.clone()),
3065                _ => None,
3066            })
3067            .expect("the refusal must reach the client as a terminal error");
3068        assert_eq!(
3069            message,
3070            crate::assistant::AUTH_REQUIRED_SIGNED_OUT_MESSAGE,
3071            "the remedy is the daemon's approved copy, verbatim"
3072        );
3073        assert!(
3074            matches!(kinds.last(), Some(DiscussEventKind::TurnComplete {})),
3075            "the turn must end with TurnComplete: {kinds:?}"
3076        );
3077
3078        // …and the discussion is usable again, which is what `TurnComplete`
3079        // promises a client.
3080        send_message(&state, &id, "owner-1", "and the second question?")
3081            .await
3082            .expect("a completed turn must accept the next message");
3083    }
3084
3085    /// A second `send` while a turn is streaming is refused, not silently
3086    /// interleaved — and `promote` refuses too rather than distilling a
3087    /// question with no answer beside it.
3088    #[tokio::test]
3089    async fn a_turn_in_flight_blocks_a_second_send_and_promote() {
3090        let repo = tempfile::tempdir().unwrap();
3091        init_repo(repo.path());
3092        let (state, _journal) = state();
3093        let gate = Arc::new(tokio::sync::Notify::new());
3094        let generator: Arc<dyn TurnGenerator> = Arc::new(Blocking { gate: gate.clone() });
3095
3096        let id = start(&state, repo.path(), generator).await;
3097        send_message(&state, &id, "owner-1", "first question")
3098            .await
3099            .unwrap();
3100
3101        let entry = get_discussion(&state, &id).await.unwrap();
3102        for _ in 0..200 {
3103            if entry.is_answering() {
3104                break;
3105            }
3106            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
3107        }
3108        assert!(entry.is_answering(), "the turn should be in flight");
3109
3110        let err = send_message(&state, &id, "owner-1", "second question")
3111            .await
3112            .unwrap_err();
3113        assert!(
3114            err.contains("still answering"),
3115            "a concurrent send must be refused, not silently lose a turn: {err}"
3116        );
3117        let err = promote(&state, &id, "owner-1").await.unwrap_err();
3118        assert!(
3119            err.contains("still answering"),
3120            "promote must not distill a half-finished turn: {err}"
3121        );
3122
3123        gate.notify_one();
3124        wait_for_turn_complete(&entry).await;
3125    }
3126
3127    /// Closing cancels the in-flight turn rather than leaving it billing tokens
3128    /// to a conversation nobody can read.
3129    #[tokio::test]
3130    async fn close_cancels_an_in_flight_turn() {
3131        let repo = tempfile::tempdir().unwrap();
3132        init_repo(repo.path());
3133        let (state, _journal) = state();
3134        let gate = Arc::new(tokio::sync::Notify::new());
3135        let generator: Arc<dyn TurnGenerator> = Arc::new(Blocking { gate });
3136
3137        let id = start(&state, repo.path(), generator).await;
3138        send_message(&state, &id, "owner-1", "a broad question")
3139            .await
3140            .unwrap();
3141        let entry = get_discussion(&state, &id).await.unwrap();
3142        for _ in 0..200 {
3143            if entry.is_answering() {
3144                break;
3145            }
3146            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
3147        }
3148
3149        close(&state, &id, "owner-1").await.unwrap();
3150        assert!(!entry.is_answering(), "close must stop the turn");
3151        assert!(state.coder_discussions.lock().await.is_empty());
3152    }
3153
3154    /// A `send` whose handler future is dropped after the turn was dispatched
3155    /// must NOT leave the discussion latched as answering.
3156    ///
3157    /// `coder.discuss.send` is not deadline-exempt, so the daemon's handler
3158    /// deadline cancels this future at its one remaining await — the turn's
3159    /// first-event cursor. `in_flight` is set by CAS before that and cleared
3160    /// only at the tail of the spawned turn task, so the question is whether
3161    /// that task exists. It does: the dispatch is complete before this await is
3162    /// ever reached, so the drop costs the caller its `seq` reply and nothing
3163    /// else. The turn answers the message, releases `in_flight`, and the
3164    /// discussion is usable again — rather than answering "still answering the
3165    /// previous message" forever with nothing running (`reap_idle` runs only on
3166    /// the next `discuss.start`, so a quiet daemon never reclaimed that).
3167    #[tokio::test]
3168    async fn a_send_cancelled_after_dispatch_leaves_the_discussion_usable() {
3169        let repo = tempfile::tempdir().unwrap();
3170        init_repo(repo.path());
3171        let (state, _journal) = state();
3172        let script: Arc<dyn TurnGenerator> = Arc::new(Script {
3173            turns: vec![
3174                turn("answered anyway", json!([])),
3175                turn("answered on the retry", json!([])),
3176            ],
3177            cursor: AtomicUsize::new(0),
3178        });
3179        let id = start(&state, repo.path(), script).await;
3180        let entry = get_discussion(&state, &id).await.unwrap();
3181
3182        // Cancellation IS "the future is dropped at an .await point" — that is
3183        // all `tokio::time::timeout` does to a handler. Poll once to get past
3184        // the CAS and the dispatch, park on the turn's first-event cursor, then
3185        // drop it there.
3186        let mut send = Box::pin(send_message(
3187            &state,
3188            &id,
3189            "owner-1",
3190            "the message whose reply frame gets cancelled",
3191        ));
3192        assert!(
3193            matches!(futures::poll!(send.as_mut()), std::task::Poll::Pending),
3194            "the fixture needs the send parked on its cursor"
3195        );
3196        assert!(
3197            entry.is_answering(),
3198            "the fixture needs the CAS to have run"
3199        );
3200        drop(send);
3201
3202        // The turn was already dispatched, so it runs and releases the latch.
3203        wait_for_turn_complete(&entry).await;
3204        for _ in 0..200 {
3205            if !entry.is_answering() {
3206                break;
3207            }
3208            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
3209        }
3210        assert!(
3211            !entry.is_answering(),
3212            "a cancelled handler must not strand `in_flight`"
3213        );
3214
3215        // ...and the discussion still works.
3216        send_message(&state, &id, "owner-1", "second try")
3217            .await
3218            .expect("the discussion must still accept a message");
3219    }
3220
3221    /// A `send` whose dispatch is refused by a `close` must never reach the
3222    /// model.
3223    ///
3224    /// `cancel_turn` used to read `turn_task` before `send_message` stored it —
3225    /// the store happened only after the first `emit().await` — so a close in
3226    /// that window found nothing to abort, removed the entry from the registry,
3227    /// and then `send_message` resumed and spawned a 12-turn model loop against
3228    /// a discussion nothing could reach. The turn slot latch is what closed
3229    /// that: `close` latches it, the dispatch checks it under the same lock,
3230    /// and a send that arrives after the latch is REFUSED. Here the latch is
3231    /// set without removing the registry entry, so the send reaches the
3232    /// dispatch and is refused exactly there.
3233    #[tokio::test]
3234    async fn a_close_racing_a_dispatching_send_never_starts_the_turn() {
3235        let repo = tempfile::tempdir().unwrap();
3236        init_repo(repo.path());
3237        let (state, _journal) = state();
3238        let calls = Arc::new(AtomicUsize::new(0));
3239        let script: Arc<dyn TurnGenerator> = Arc::new(Counting {
3240            calls: calls.clone(),
3241        });
3242        let id = start(&state, repo.path(), script).await;
3243        let entry = get_discussion(&state, &id).await.unwrap();
3244
3245        entry.cancel_turn();
3246
3247        let err = send_message(&state, &id, "owner-1", "a broad question")
3248            .await
3249            .unwrap_err();
3250        assert!(
3251            err.contains("closed while your message was being dispatched"),
3252            "the caller must be told the send did not run: {err}"
3253        );
3254        assert_eq!(
3255            calls.load(Ordering::SeqCst),
3256            0,
3257            "a closed discussion must never reach the model"
3258        );
3259        assert!(!entry.is_answering());
3260
3261        close(&state, &id, "owner-1").await.unwrap();
3262        assert!(state.coder_discussions.lock().await.is_empty());
3263    }
3264
3265    /// A discussion is owned by the connection that opened it — and that is now
3266    /// enforced, not merely recorded. Every method resolved by id alone, so any
3267    /// connected client could send into, promote, or close another's
3268    /// discussion; closing one mid-turn cancels a turn its owner is watching.
3269    #[tokio::test]
3270    async fn another_connection_cannot_drive_a_discussion() {
3271        let repo = tempfile::tempdir().unwrap();
3272        init_repo(repo.path());
3273        let (state, _journal) = state();
3274        let script: Arc<dyn TurnGenerator> = Arc::new(Script {
3275            turns: vec![],
3276            cursor: AtomicUsize::new(0),
3277        });
3278        let id = start(&state, repo.path(), script).await;
3279
3280        for err in [
3281            send_message(&state, &id, "intruder", "run this for me")
3282                .await
3283                .unwrap_err(),
3284            promote(&state, &id, "intruder").await.unwrap_err(),
3285            close(&state, &id, "intruder").await.unwrap_err(),
3286            // The path `coder.discuss.subscribe` and `coder.start
3287            // { discussion_id }` both resolve through.
3288            match get_owned_discussion(&state, &id, "intruder").await {
3289                Ok(_) => panic!("a foreign client must not resolve another's discussion"),
3290                Err(e) => e,
3291            },
3292        ] {
3293            assert!(
3294                err.contains("belongs to another connection"),
3295                "a foreign client must be refused: {err}"
3296            );
3297        }
3298
3299        // Untouched, and still the owner's to close.
3300        assert_eq!(state.coder_discussions.lock().await.len(), 1);
3301        close(&state, &id, "owner-1").await.unwrap();
3302    }
3303
3304    /// Operator text is retained in the transcript, the replay buffer and the
3305    /// distill prompt, so it needs the byte cap `summarize_repo` already has.
3306    #[tokio::test]
3307    async fn an_oversized_message_is_refused() {
3308        let repo = tempfile::tempdir().unwrap();
3309        init_repo(repo.path());
3310        let (state, _journal) = state();
3311        let script: Arc<dyn TurnGenerator> = Arc::new(Script {
3312            turns: vec![],
3313            cursor: AtomicUsize::new(0),
3314        });
3315        let id = start(&state, repo.path(), script).await;
3316        let entry = get_discussion(&state, &id).await.unwrap();
3317
3318        let err = send_message(
3319            &state,
3320            &id,
3321            "owner-1",
3322            &"x".repeat(DISCUSS_MESSAGE_MAX_BYTES + 1),
3323        )
3324        .await
3325        .unwrap_err();
3326        assert!(err.contains("the limit is"), "{err}");
3327        // Refused BEFORE the latch, so the discussion is still usable.
3328        assert!(!entry.is_answering());
3329        assert!(entry.transcript_is_empty());
3330    }
3331
3332    /// A disconnecting client's discussions are freed, not leaked for the
3333    /// daemon's lifetime.
3334    #[tokio::test]
3335    async fn disconnect_closes_the_owning_clients_discussions() {
3336        let repo = tempfile::tempdir().unwrap();
3337        init_repo(repo.path());
3338        let (state, _journal) = state();
3339        let script: Arc<dyn TurnGenerator> = Arc::new(Script {
3340            turns: vec![],
3341            cursor: AtomicUsize::new(0),
3342        });
3343        let id = start(&state, repo.path(), script).await;
3344        assert_eq!(state.coder_discussions.lock().await.len(), 1);
3345
3346        // A different client disconnecting leaves it alone...
3347        drop_subscriptions_for_client(&state, "someone-else").await;
3348        assert_eq!(state.coder_discussions.lock().await.len(), 1);
3349
3350        // ...its owner disconnecting closes it.
3351        drop_subscriptions_for_client(&state, "owner-1").await;
3352        assert!(state.coder_discussions.lock().await.is_empty());
3353        assert!(get_discussion(&state, &id).await.is_err());
3354    }
3355
3356    /// The cap is a slot RESERVATION, so the test holds the slots directly
3357    /// rather than building eight full assistant runtimes — each
3358    /// `start_discussion` binds a substrate and registers ~40 tools, and doing
3359    /// that eight times to assert a length check cost minutes of CI for
3360    /// nothing.
3361    #[tokio::test]
3362    async fn open_discussions_are_capped() {
3363        let repo = tempfile::tempdir().unwrap();
3364        init_repo(repo.path());
3365        let (state, _journal) = state();
3366        let held: Vec<_> = (0..MAX_OPEN_DISCUSSIONS)
3367            .map(|_| {
3368                state
3369                    .coder_discussion_slots
3370                    .clone()
3371                    .try_acquire_owned()
3372                    .expect("a fresh daemon has every slot free")
3373            })
3374            .collect();
3375
3376        let script: Arc<dyn TurnGenerator> = Arc::new(Script {
3377            turns: vec![],
3378            cursor: AtomicUsize::new(0),
3379        });
3380        let err = start_discussion(&state, repo.path(), "owner-1", engine(repo.path()), script)
3381            .await
3382            .unwrap_err();
3383        assert!(err.contains("already open"), "{err}");
3384
3385        // ...and a freed slot admits the next one.
3386        drop(held);
3387        let script: Arc<dyn TurnGenerator> = Arc::new(Script {
3388            turns: vec![],
3389            cursor: AtomicUsize::new(0),
3390        });
3391        start_discussion(&state, repo.path(), "owner-1", engine(repo.path()), script)
3392            .await
3393            .expect("a released slot must be reusable");
3394    }
3395
3396    /// The cap must hold under CONCURRENT starts, which is what it did not do:
3397    /// the count was read, the registry lock released, and two awaits (bind the
3398    /// substrate, build the runtime) ran before the insert — and the daemon
3399    /// runs a connection's requests concurrently, so N pipelined starts all
3400    /// read `len() == 0`, all passed a cap of 8, and all built a runtime.
3401    ///
3402    /// One slot is left free and four starts race for it: exactly one may win,
3403    /// and the three losers must fail BEFORE building anything.
3404    #[tokio::test]
3405    async fn concurrent_starts_cannot_exceed_the_open_discussion_cap() {
3406        let repo = tempfile::tempdir().unwrap();
3407        init_repo(repo.path());
3408        let (state, _journal) = state();
3409        let _held: Vec<_> = (0..MAX_OPEN_DISCUSSIONS - 1)
3410            .map(|_| {
3411                state
3412                    .coder_discussion_slots
3413                    .clone()
3414                    .try_acquire_owned()
3415                    .unwrap()
3416            })
3417            .collect();
3418
3419        let mut racers = Vec::new();
3420        for _ in 0..4 {
3421            let state = state.clone();
3422            let repo = repo.path().to_path_buf();
3423            racers.push(tokio::spawn(async move {
3424                let script: Arc<dyn TurnGenerator> = Arc::new(Script {
3425                    turns: vec![],
3426                    cursor: AtomicUsize::new(0),
3427                });
3428                start_discussion(&state, &repo, "owner-1", engine(&repo), script).await
3429            }));
3430        }
3431
3432        let mut admitted = 0;
3433        let mut refused = 0;
3434        for racer in racers {
3435            match racer.await.unwrap() {
3436                Ok(_) => admitted += 1,
3437                Err(e) => {
3438                    assert!(e.contains("already open"), "unexpected refusal: {e}");
3439                    refused += 1;
3440                }
3441            }
3442        }
3443        assert_eq!(admitted, 1, "exactly one racer may take the last slot");
3444        assert_eq!(refused, 3);
3445        assert_eq!(
3446            state.coder_discussions.lock().await.len(),
3447            1,
3448            "the registry must never exceed the cap"
3449        );
3450    }
3451
3452    #[tokio::test]
3453    async fn unknown_discussion_ids_are_clear_errors() {
3454        let (state, _journal) = state();
3455        for err in [
3456            send_message(&state, "disc-nope", "owner-1", "hi")
3457                .await
3458                .unwrap_err(),
3459            promote(&state, "disc-nope", "owner-1").await.unwrap_err(),
3460            constraints_for_start(&state, "disc-nope")
3461                .await
3462                .unwrap_err(),
3463        ] {
3464            assert!(err.contains("disc-nope"), "must name the id, got: {err}");
3465        }
3466        assert!(close(&state, "disc-nope", "owner-1").await.is_err());
3467    }
3468
3469    #[test]
3470    fn checkout_followup_uses_current_files_but_requires_a_known_delivery() {
3471        assert_eq!(
3472            followup_base_from(&[
3473                json!({"state":"merged", "result_delivery":"checkout", "result_commit":"saved"})
3474            ])
3475            .unwrap(),
3476            None
3477        );
3478        assert!(
3479            followup_base_from(&[json!({"state":"merged", "result_delivery":"checkout"})]).is_err()
3480        );
3481        assert_eq!(
3482            followup_base_from(&[
3483                json!({"state":"merged", "result_delivery":"branch", "result_commit":"saved"})
3484            ])
3485            .unwrap(),
3486            Some("saved".into())
3487        );
3488    }
3489
3490    #[test]
3491    fn followup_never_substitutes_head_for_known_work() {
3492        assert!(followup_base_from(&[
3493            json!({"state": "merged", "updated_at": 1, "result_commit": "one"}),
3494            json!({"state": "merged", "updated_at": 1, "result_commit": "two"}),
3495        ])
3496        .is_err());
3497        assert!(
3498            followup_base_from(&[json!({"state": "merged", "result_branch": "moved"})]).is_err()
3499        );
3500        assert!(
3501            followup_base_from(&[json!({"state": "failed", "worktree": "/retained"})]).is_err()
3502        );
3503        let rows = vec![
3504            json!({"state": "failed", "worktree": null}),
3505            json!({"state": "merged", "result_commit": "fixed-revision"}),
3506        ];
3507        assert_eq!(
3508            followup_base_from(&rows).unwrap().as_deref(),
3509            Some("fixed-revision")
3510        );
3511    }
3512
3513    #[tokio::test]
3514    async fn linked_work_is_grounded_scoped_and_blocks_overlapping_starts() {
3515        use super::super::router::EngineChoice;
3516        use super::super::session::{CoderSession, CoderState};
3517        let repo = tempfile::tempdir().unwrap();
3518        init_repo(repo.path());
3519        let (state, _journal) = state();
3520        let dir = tempfile::tempdir().unwrap();
3521        let id = start(
3522            &state,
3523            repo.path(),
3524            Arc::new(Script {
3525                turns: vec![],
3526                cursor: AtomicUsize::new(0),
3527            }),
3528        )
3529        .await;
3530        let entry = get_discussion(&state, &id).await.unwrap();
3531        assert!(
3532            claim_coding_start(&state, &id, Path::new("/different"), dir.path().into())
3533                .await
3534                .unwrap_err()
3535                .contains("repository")
3536        );
3537        let guard = claim_coding_start_at(&state, &id, dir.path().into())
3538            .await
3539            .unwrap();
3540        assert!(tokio::time::timeout(
3541            std::time::Duration::from_millis(20),
3542            claim_coding_start_at(&state, &id, dir.path().into())
3543        )
3544        .await
3545        .is_err());
3546        let mut run = CoderSession::new(
3547            &entry.repo,
3548            "repair export",
3549            EngineChoice::Native,
3550            3,
3551            Some(dir.path().into()),
3552        );
3553        run.discussion_id = Some(id.clone());
3554        run.state = CoderState::NeedsApproval;
3555        run.workspace_path = Some(repo.path().into());
3556        run.persist().unwrap();
3557        drop(guard);
3558        let error = claim_coding_start_at(&state, &id, dir.path().into())
3559            .await
3560            .unwrap_err();
3561        assert!(error.contains(&run.id));
3562        assert!(error.contains("unfinished work"));
3563        run.state = CoderState::Failed;
3564        run.error = Some("export check failed".into());
3565        run.persist().unwrap();
3566        let mut unrelated = CoderSession::new(
3567            &entry.repo,
3568            "private other conversation",
3569            EngineChoice::Native,
3570            3,
3571            Some(dir.path().into()),
3572        );
3573        unrelated.discussion_id = Some("other".into());
3574        unrelated.persist().unwrap();
3575        let rows = coding_runs(&state, &id, &entry.repo, dir.path().into())
3576            .await
3577            .unwrap();
3578        assert_eq!(rows.len(), 1);
3579        assert_eq!(rows[0]["error"], "export check failed");
3580        assert_eq!(rows[0]["live"], false);
3581        assert!(
3582            retained_workspace(&state, &id, &entry.repo, dir.path().into())
3583                .await
3584                .unwrap_err()
3585                .contains("not been confirmed stopped")
3586        );
3587        run.execution_stopped = true;
3588        run.persist().unwrap();
3589        assert!(
3590            retained_workspace(&state, &id, &entry.repo, dir.path().into())
3591                .await
3592                .is_err(),
3593            "a stopped task cannot adopt a path outside the daemon's worktrees"
3594        );
3595        assert!(
3596            coding_runs(&state, &id, Path::new("/different"), dir.path().into())
3597                .await
3598                .unwrap()
3599                .is_empty()
3600        );
3601        assert!(claim_coding_start_at(&state, &id, dir.path().into())
3602            .await
3603            .is_ok());
3604        close(&state, &id, "owner-1").await.unwrap();
3605    }
3606
3607    #[tokio::test]
3608    async fn saved_list_survives_restart_and_filters_principals_and_live_owners() {
3609        let repo = tempfile::tempdir().unwrap();
3610        init_repo(repo.path());
3611        let (state, journal) = state();
3612        let owner = client(&state, "operator-1").await;
3613        let other = client(&state, "agent-1").await;
3614        *other.agent_id.lock().await = Some("foreign".into());
3615        let script: Arc<dyn TurnGenerator> = Arc::new(Script {
3616            turns: vec![],
3617            cursor: AtomicUsize::new(0),
3618        });
3619        let opened = open_discussion(
3620            &state,
3621            repo.path(),
3622            &owner.client_id,
3623            engine(repo.path()),
3624            script,
3625            "operator",
3626            None,
3627        )
3628        .await
3629        .unwrap();
3630        let id = opened["discussion_id"].as_str().unwrap();
3631        assert!(handle_discuss_list(&state, &owner).await.unwrap()["saved"]
3632            .as_array()
3633            .unwrap()
3634            .is_empty());
3635        close(&state, id, &owner.client_id).await.unwrap();
3636        let restarted = Arc::new(ServerState::standalone(journal.path().to_path_buf()));
3637        let saved = handle_discuss_list(&restarted, &owner).await.unwrap();
3638        assert_eq!(saved["saved"][0]["discussion_id"], id);
3639        assert!(saved["discussions"].as_array().unwrap().is_empty());
3640        assert!(
3641            handle_discuss_list(&restarted, &other).await.unwrap()["saved"]
3642                .as_array()
3643                .unwrap()
3644                .is_empty()
3645        );
3646    }
3647
3648    #[tokio::test]
3649    async fn list_and_close_track_open_discussions() {
3650        let repo = tempfile::tempdir().unwrap();
3651        init_repo(repo.path());
3652        let (state, _journal) = state();
3653        let script: Arc<dyn TurnGenerator> = Arc::new(Script {
3654            turns: vec![],
3655            cursor: AtomicUsize::new(0),
3656        });
3657        let id = start(&state, repo.path(), script).await;
3658        let owner = client(&state, "owner-1").await;
3659
3660        let listed = handle_discuss_list(&state, &owner).await.unwrap();
3661        assert_eq!(listed["discussions"].as_array().unwrap().len(), 1);
3662        assert_eq!(listed["discussions"][0]["discussion_id"], id.as_str());
3663        assert_eq!(listed["discussions"][0]["turns"], 0);
3664
3665        // ...and it is scoped to the owning connection.
3666        let stranger = client(&state, "someone-else").await;
3667        let listed = handle_discuss_list(&state, &stranger).await.unwrap();
3668        assert!(
3669            listed["discussions"].as_array().unwrap().is_empty(),
3670            "another connection must not see this discussion: {listed}"
3671        );
3672
3673        assert_eq!(
3674            close(&state, &id, "owner-1").await.unwrap(),
3675            json!({ "ok": true })
3676        );
3677        let listed = handle_discuss_list(&state, &owner).await.unwrap();
3678        assert!(listed["discussions"].as_array().unwrap().is_empty());
3679    }
3680
3681    /// The stated guarantee, measured where a client actually lives: what a
3682    /// SUBSCRIBER receives across an attach is contiguous from its cursor —
3683    /// no gap, no duplicate — even when emits are racing the attach.
3684    ///
3685    /// Asserting on the buffer proves only that the drain is the single writer.
3686    /// The property clients depend on spans three more hops the buffer never
3687    /// touches: the replay clone at attach, the per-subscriber queue, and that
3688    /// lane's sender task. An attach that registered before replaying would
3689    /// duplicate here and an attach that replayed before registering would drop
3690    /// whatever emitted in between, and the buffer would look perfect either
3691    /// way.
3692    ///
3693    /// **The replay hop has to actually run.** `handle_discuss_subscribe` has
3694    /// exactly one await before it enqueues `Attach`, and it resolves on the
3695    /// first poll; on the current-thread test runtime the "racing" emitter had
3696    /// therefore never been polled when the attach landed, so
3697    /// `events_replayed` was 0 on every run and `replayed <= 30` was satisfied
3698    /// by nothing having been replayed at all. Mutating the replay filter to
3699    /// `e.seq > from_seq` — the off-by-one that drops the first event of every
3700    /// real client resume — left the test green. So: yield until the emitter
3701    /// has genuinely produced events, assert the replay is non-empty, and
3702    /// attach a second time from a NON-ZERO cursor, where an off-by-one is a
3703    /// wrong first seq rather than a merely smaller count.
3704    #[tokio::test]
3705    async fn a_subscriber_receives_every_seq_exactly_once_across_its_attach() {
3706        let repo = tempfile::tempdir().unwrap();
3707        init_repo(repo.path());
3708        let (state, _journal) = state();
3709        let script: Arc<dyn TurnGenerator> = Arc::new(Script {
3710            turns: vec![],
3711            cursor: AtomicUsize::new(0),
3712        });
3713        let id = start(&state, repo.path(), script).await;
3714        let entry = get_discussion(&state, &id).await.unwrap();
3715
3716        let (channel, frames) = capturing_channel();
3717        let owner = state
3718            .create_session("owner-1", channel.clone())
3719            .await
3720            .unwrap();
3721
3722        // Emitted WHILE the attach is in flight: each of these lands on one
3723        // side or the other of the `Attach` command, and the subscriber must
3724        // see it exactly once either way.
3725        let racing = {
3726            let entry = entry.clone();
3727            tokio::spawn(async move {
3728                for i in 0..30u64 {
3729                    entry
3730                        .emit(DiscussEventKind::AssistantDelta {
3731                            text: format!("during-{i}"),
3732                        })
3733                        .await;
3734                }
3735            })
3736        };
3737        // Let the emitter actually get ahead of the attach. Without this the
3738        // attach wins every poll and there is no race to observe.
3739        while entry.events.lock().await.is_empty() {
3740            tokio::task::yield_now().await;
3741        }
3742        let subscribed = handle_discuss_subscribe(
3743            &rpc_req(json!({ "discussion_id": id, "from_seq": 0 })),
3744            &state,
3745            &owner,
3746        )
3747        .await
3748        .unwrap();
3749        racing.await.unwrap();
3750
3751        // ...and after it, live through the same lane.
3752        for i in 0..20u64 {
3753            entry
3754                .emit(DiscussEventKind::AssistantDelta {
3755                    text: format!("after-{i}"),
3756                })
3757                .await;
3758        }
3759
3760        const TOTAL: usize = 50;
3761        let replayed = subscribed["events_replayed"].as_u64().unwrap();
3762        assert!(
3763            replayed > 0,
3764            "the attach replayed nothing, so this test never exercised the \
3765             replay hop it exists to cover"
3766        );
3767        assert!(
3768            replayed <= 30,
3769            "replay cannot exceed what was emitted before the attach: {replayed}"
3770        );
3771
3772        let mut seqs = Vec::new();
3773        for _ in 0..400 {
3774            seqs = delivered_seqs(&frames);
3775            if seqs.len() >= TOTAL {
3776                break;
3777            }
3778            tokio::time::sleep(std::time::Duration::from_millis(25)).await;
3779        }
3780        assert_eq!(
3781            seqs,
3782            (0..TOTAL as u64).collect::<Vec<_>>(),
3783            "a subscriber must receive seq 0..{TOTAL} once each, in order"
3784        );
3785
3786        // ...and a resume from a non-zero cursor is inclusive of that cursor.
3787        // Every seq is in the buffer now, so this is exact: an off-by-one in
3788        // the replay filter shows up as a missing FIRST event, not as a count
3789        // that merely looks plausible.
3790        const RESUME_FROM: u64 = 17;
3791        let (resumed_channel, resumed_frames) = capturing_channel();
3792        // Same client id: a discussion is owned by the connection that opened
3793        // it, and re-attaching replaces that connection's lane.
3794        let resumed = state
3795            .create_session("owner-1", resumed_channel)
3796            .await
3797            .unwrap();
3798        let reattached = handle_discuss_subscribe(
3799            &rpc_req(json!({ "discussion_id": id, "from_seq": RESUME_FROM })),
3800            &state,
3801            &resumed,
3802        )
3803        .await
3804        .unwrap();
3805        assert_eq!(
3806            reattached["events_replayed"].as_u64().unwrap(),
3807            TOTAL as u64 - RESUME_FROM,
3808            "a resume from {RESUME_FROM} must replay seq {RESUME_FROM}..{TOTAL}"
3809        );
3810
3811        let mut resumed_seqs = Vec::new();
3812        for _ in 0..400 {
3813            resumed_seqs = delivered_seqs(&resumed_frames);
3814            if resumed_seqs.len() >= TOTAL - RESUME_FROM as usize {
3815                break;
3816            }
3817            tokio::time::sleep(std::time::Duration::from_millis(25)).await;
3818        }
3819        assert_eq!(
3820            resumed_seqs,
3821            (RESUME_FROM..TOTAL as u64).collect::<Vec<_>>(),
3822            "a resume must start AT its cursor, not one past it"
3823        );
3824    }
3825
3826    /// A send that IS dispatched still puts the `user_message` on the
3827    /// stream first, ahead of every assistant delta for that turn. Moving the
3828    /// emit into the turn must not reorder it behind the turn's own output.
3829    #[tokio::test]
3830    async fn a_dispatched_send_emits_the_user_message_before_any_delta() {
3831        let repo = tempfile::tempdir().unwrap();
3832        init_repo(repo.path());
3833        let (state, _journal) = state();
3834        let script: Arc<dyn TurnGenerator> = Arc::new(Script {
3835            turns: vec![turn("here is what I would change", json!([]))],
3836            cursor: AtomicUsize::new(0),
3837        });
3838        let id = start(&state, repo.path(), script).await;
3839        let entry = get_discussion(&state, &id).await.unwrap();
3840
3841        let sent = send_message(&state, &id, "owner-1", "what should this change do?")
3842            .await
3843            .unwrap();
3844        assert_eq!(
3845            sent["seq"], 0,
3846            "the reported cursor is the user_message's own seq"
3847        );
3848        wait_for_turn_complete(&entry).await;
3849
3850        let events = entry.events.lock().await;
3851        assert!(
3852            matches!(events[0].kind, DiscussEventKind::UserMessage { .. }),
3853            "the operator's message must be the turn's first event, got: {:?}",
3854            events[0].kind
3855        );
3856        assert!(
3857            events.len() > 1,
3858            "the turn produced nothing to order against"
3859        );
3860        assert!(
3861            !events[1..]
3862                .iter()
3863                .any(|e| matches!(e.kind, DiscussEventKind::UserMessage { .. })),
3864            "exactly one user_message per send"
3865        );
3866    }
3867
3868    /// A subscriber that has stopped reading is its own problem: it is SHED,
3869    /// and the turn it was watching completes anyway.
3870    ///
3871    /// Half of this was never pinned. When the drain performed the sends
3872    /// itself, a half-open board (no FIN, no RST — writes park forever) held
3873    /// the drain for `DISCUSS_SEND_TIMEOUT` per event, so the next `Emit` sat
3874    /// unprocessed and every `entry.emit(…).await` inside `run_turn` waited on
3875    /// it: one dead board stalled the whole TURN. The turn here must complete
3876    /// while the wedge is still in place, on a clock well inside that deadline.
3877    #[tokio::test]
3878    async fn a_wedged_subscriber_is_shed_and_the_turn_still_completes() {
3879        let repo = tempfile::tempdir().unwrap();
3880        init_repo(repo.path());
3881        let (state, _journal) = state();
3882        let script: Arc<dyn TurnGenerator> = Arc::new(Script {
3883            turns: vec![turn("here is what I would change", json!([]))],
3884            cursor: AtomicUsize::new(0),
3885        });
3886        let id = start(&state, repo.path(), script).await;
3887        let entry = get_discussion(&state, &id).await.unwrap();
3888
3889        let (channel, _frames) = capturing_channel();
3890        let owner = state
3891            .create_session("owner-1", channel.clone())
3892            .await
3893            .unwrap();
3894        let unsubscribed = Arc::strong_count(&channel);
3895        handle_discuss_subscribe(
3896            &rpc_req(json!({ "discussion_id": id, "from_seq": 0 })),
3897            &state,
3898            &owner,
3899        )
3900        .await
3901        .unwrap();
3902        assert_eq!(
3903            Arc::strong_count(&channel),
3904            unsubscribed + 1,
3905            "the lane must hold this subscriber's channel"
3906        );
3907
3908        // Half-open from here on: writes never fail, they just never finish.
3909        let stuck = channel.write.lock().await;
3910
3911        let started = std::time::Instant::now();
3912        send_message(&state, &id, "owner-1", "what should this change do?")
3913            .await
3914            .unwrap();
3915        let mut completed = false;
3916        for _ in 0..120 {
3917            if entry
3918                .events
3919                .lock()
3920                .await
3921                .iter()
3922                .any(|e| matches!(e.kind, DiscussEventKind::TurnComplete {}))
3923            {
3924                completed = true;
3925                break;
3926            }
3927            tokio::time::sleep(std::time::Duration::from_millis(25)).await;
3928        }
3929        assert!(
3930            completed && started.elapsed() < DISCUSS_SEND_TIMEOUT,
3931            "the turn must not wait on a wedged subscriber's socket ({:?} elapsed)",
3932            started.elapsed()
3933        );
3934
3935        // ...and the lane is shed rather than carried: its queue fills, the
3936        // drain's `try_send` fails, and dropping the `Subscriber` aborts the
3937        // task parked on that socket — releasing the channel handle it pinned.
3938        for i in 0..(DISCUSS_SUBSCRIBER_QUEUE + 64) {
3939            entry
3940                .emit(DiscussEventKind::AssistantDelta {
3941                    text: format!("overflow-{i}"),
3942                })
3943                .await;
3944        }
3945        let mut shed = false;
3946        for _ in 0..200 {
3947            if Arc::strong_count(&channel) == unsubscribed {
3948                shed = true;
3949                break;
3950            }
3951            tokio::time::sleep(std::time::Duration::from_millis(25)).await;
3952        }
3953        assert!(
3954            shed,
3955            "a subscriber that is not draining must be shed, not retained"
3956        );
3957        drop(stuck);
3958    }
3959
3960    /// A `send` whose dispatch is refused must leave no unanswered operator
3961    /// question behind — not in the transcript, and not on the wire.
3962    ///
3963    /// `InFlightGuard` frees the discussion on that path, so `is_answering()`
3964    /// reads false — and `promote` and `coder.start { discussion_id }` gate on
3965    /// exactly that. The transcript still ended in a question no turn answered,
3966    /// which sailed through both guards and became the distillation input those
3967    /// guards exist to prevent: a confident intent invented from a question
3968    /// nobody replied to.
3969    ///
3970    /// The `user_message` event had the same hole for the same reason: it was
3971    /// emitted BEFORE the dispatch, so a refused send still put the operator's
3972    /// question in the replay buffer and on every subscriber while the
3973    /// transcript row rolled back — and the board's discussion pane rendered
3974    /// that question followed by permanent silence. The emit now happens inside
3975    /// the turn, so it and the transcript row commit or roll back together.
3976    #[tokio::test]
3977    async fn a_refused_send_leaves_no_unanswered_turn_in_the_transcript() {
3978        let repo = tempfile::tempdir().unwrap();
3979        init_repo(repo.path());
3980        let (state, _journal) = state();
3981        let calls = Arc::new(AtomicUsize::new(0));
3982        let script: Arc<dyn TurnGenerator> = Arc::new(Counting {
3983            calls: calls.clone(),
3984        });
3985        let id = start(&state, repo.path(), script).await;
3986        let entry = get_discussion(&state, &id).await.unwrap();
3987
3988        // Latch the turn slot closed WITHOUT removing the registry entry, so
3989        // the send reaches the dispatch and is refused THERE — the window a
3990        // racing `close` actually wins.
3991        entry.cancel_turn();
3992        let err = send_message(&state, &id, "owner-1", "should we rewrite the scheduler?")
3993            .await
3994            .unwrap_err();
3995        assert!(
3996            err.contains("closed while your message was being dispatched"),
3997            "expected a refused dispatch, got: {err}"
3998        );
3999
4000        assert!(
4001            !entry.is_answering(),
4002            "a refused dispatch must not strand `in_flight`"
4003        );
4004        assert!(
4005            entry.transcript_is_empty(),
4006            "a question no turn will answer must not survive in the transcript: {:?}",
4007            lock(&entry.transcript)
4008        );
4009        assert!(
4010            !entry
4011                .events
4012                .lock()
4013                .await
4014                .iter()
4015                .any(|e| matches!(e.kind, DiscussEventKind::UserMessage { .. })),
4016            "...nor reach the replay buffer and every subscriber"
4017        );
4018        // ...and the guards that read the transcript agree.
4019        let err = promote(&state, &id, "owner-1").await.unwrap_err();
4020        assert!(
4021            err.contains("no turns yet"),
4022            "promote must refuse an empty discussion rather than distill a stranded \
4023             question: {err}"
4024        );
4025        assert!(
4026            constraints_for_start(&state, &id).await.unwrap().is_empty(),
4027            "coder.start must not distill constraints from a stranded question"
4028        );
4029        assert_eq!(
4030            calls.load(Ordering::SeqCst),
4031            0,
4032            "no turn ran, so nothing reached the model"
4033        );
4034    }
4035
4036    /// The drain assigns `seq` under the buffer lock as the only writer, so the
4037    /// buffer is strictly ordered even when emits are produced concurrently.
4038    #[tokio::test]
4039    async fn concurrent_emits_stay_seq_ordered_in_the_buffer() {
4040        let repo = tempfile::tempdir().unwrap();
4041        init_repo(repo.path());
4042        let (state, _journal) = state();
4043        let script: Arc<dyn TurnGenerator> = Arc::new(Script {
4044            turns: vec![],
4045            cursor: AtomicUsize::new(0),
4046        });
4047        let id = start(&state, repo.path(), script).await;
4048        let entry = get_discussion(&state, &id).await.unwrap();
4049
4050        let mut tasks = Vec::new();
4051        for i in 0..50 {
4052            let e = entry.clone();
4053            tasks.push(tokio::spawn(async move {
4054                e.emit(DiscussEventKind::AssistantDelta {
4055                    text: format!("chunk-{i}"),
4056                })
4057                .await
4058            }));
4059        }
4060        for t in tasks {
4061            t.await.unwrap();
4062        }
4063
4064        let events = entry.events.lock().await;
4065        assert_eq!(events.len(), 50);
4066        for (i, e) in events.iter().enumerate() {
4067            assert_eq!(e.seq, i as u64, "buffer must be in seq order");
4068        }
4069    }
4070
4071    #[test]
4072    fn discuss_event_json_shape_is_ws_friendly() {
4073        let e = DiscussEvent {
4074            discussion_id: "disc-x".into(),
4075            seq: 7,
4076            ts: 1,
4077            kind: DiscussEventKind::AssistantDelta {
4078                text: "hello".into(),
4079            },
4080        };
4081        let v = serde_json::to_value(&e).unwrap();
4082        assert_eq!(v["type"], "assistant_delta");
4083        assert_eq!(v["text"], "hello");
4084        assert_eq!(v["seq"], 7);
4085        assert_eq!(v["discussion_id"], "disc-x");
4086
4087        let v = serde_json::to_value(DiscussEvent {
4088            discussion_id: "disc-x".into(),
4089            seq: 8,
4090            ts: 1,
4091            kind: DiscussEventKind::TurnComplete {},
4092        })
4093        .unwrap();
4094        assert_eq!(v["type"], "turn_complete");
4095    }
4096}