Skip to main content

agentmux_srv/server/
muxspect_handlers.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! HTTP handlers backing the `muxspect` CLI — Phase 1 of
5//! `docs/specs/SPEC_MUXSPECT_LIVE_INTROSPECTION_TOOL_2026_08_01.md`, plus
6//! the `dock`/`dock clear` extension from
7//! `docs/specs/SPEC_MUXSPECT_DOCK_DIAGNOSIS_AND_REMEDIATION_2026_08_06.md`.
8//!
9//! Diagnostic-only surface: a thin read composition over `ProcessBroker`
10//! (Phase A of the process-tracking consolidation,
11//! `crates/srv/src/broker/process.rs`) and its sibling registries — never
12//! a new independent snapshot of process/turn state (spec §5.1/§3 point 8).
13//! Reached the same way `agentmux-mcp` already reaches every other
14//! `/api/v1/*` route: plain HTTP, `X-AuthKey` header, `$AGENTMUX_LOCAL_URL`/
15//! `$AGENTMUX_AUTH_KEY` inherited from the caller's own environment (spec
16//! §5.2) — no new IPC mechanism, no new auth scheme. `dock clear` is this
17//! module's first MUTATING route — see the 2026-08-06 spec §2 for why that
18//! scope call is narrow and deliberate, not a reversal of the read-only
19//! design generally.
20
21use axum::extract::{Query, State};
22use axum::http::StatusCode;
23use axum::response::{IntoResponse, Json};
24use serde_json::json;
25
26use crate::backend::storage::filestore::FileStore;
27
28use super::AppState;
29
30/// Bounded tail window read from a block's own persisted `output` file
31/// when looking for a trailing `error_during_execution` frame — see
32/// [`last_error_frame`]. Large enough to comfortably hold the frame itself
33/// (a single JSON line, typically well under 1KB) plus whatever partial
34/// line/chunking noise might precede it; small enough to stay O(1) and not
35/// turn `muxspect` into a log-walker (that's `muxlog`'s job, not this
36/// tool's — see the tool's own design spec §1).
37const LAST_ERROR_TAIL_BYTES: i64 = 8192;
38
39/// The most recent persisted `error_during_execution` frame at the tail of
40/// a block's own `output` file — populated ONLY when the very last
41/// non-blank line IS one (a block that errored once and then kept
42/// producing normal output afterward must NOT be flagged; only "the last
43/// thing that happened to this block was an unrecovered error" counts).
44///
45/// This reads a signal that already exists and is already durable: per
46/// `agent_handlers/input.rs`'s own comment, this exact frame shape is
47/// deliberately persisted to the block file (not just live-broadcast) so
48/// it survives pane reload — the same source the frontend itself renders
49/// as the pane's error bubble. Composing a second reader over that
50/// existing store, rather than adding a new independent tracker, follows
51/// this module's own design constraint (spec §5.1/§3 pt. 8).
52///
53/// See `docs/reports/REPORT_MUXSPECT_SPAWN_REFUSAL_DIAGNOSIS_EXTENSION_2026_08_03.md`
54/// for the incident this closes the diagnostic gap on: a block whose spawn
55/// was refused before any controller/process ever existed showed up as
56/// featureless "no controller, no processes" — indistinguishable from a
57/// healthy idle block — even though the actual, actionable reason was
58/// already sitting in this exact file.
59#[derive(serde::Serialize)]
60pub struct LastErrorFrame {
61    /// The frame's `error.message` verbatim (includes the `[AgentMux] `
62    /// prefix every construction site already writes).
63    pub message: String,
64    /// Best-effort tag for which pre-spawn failure path wrote the frame —
65    /// see [`classify_last_error_source`]. NOT a stable/tested taxonomy
66    /// like `agents::failure::FailureClass` (which only classifies
67    /// POST-spawn exit failures); building that properly for pre-spawn
68    /// refusals is real, separate follow-up work (report §3.3), not
69    /// attempted here.
70    pub source: &'static str,
71    /// Epoch ms the `output` file was last written — i.e. when this frame
72    /// (being the file's last line) was appended. Deliberately a raw
73    /// timestamp, not a precomputed "age," matching `last_computed_ms`'s
74    /// existing convention elsewhere in this response so `--json`
75    /// consumers get a stable value and the CLI renderer computes age the
76    /// same way it already does for every other timestamp.
77    pub written_ms: u64,
78}
79
80/// Best-effort classification of which pre-spawn failure path wrote a
81/// persisted `error_during_execution` frame, inferred from the message
82/// text itself (each construction site's message is distinct and stable —
83/// see `identity/resolver/errors.rs`'s `SpawnGateError::Display`,
84/// `subprocess/container_spawn.rs`, `subprocess/host_spawn.rs`, and
85/// `agent_handlers/input.rs`'s container `ensure_running` path, the only
86/// four call sites that build this frame today). `message` should have
87/// the `[AgentMux] ` prefix already stripped.
88///
89/// codex P2, PR #2802: `SpawnGateError::AmbientHomeDirNotAllowed`'s wording
90/// starts with "this agent's", not either prefix this function originally
91/// recognized — without this branch every ambient-home refusal reported
92/// `unknown` instead of `identity`, regressing this diagnostic for exactly
93/// the pre-spawn refusal case it exists to classify.
94///
95/// ReAgent P2, PR #2854: same regression again for
96/// `SpawnGateError::ClaudeMdSeedFailed` (a newer variant, wording starts
97/// with "could not isolate this agent's") — this function needs a new
98/// branch every time a `SpawnGateError` variant with distinct wording is
99/// added, since it infers the source from message text rather than the
100/// error's own type. A future variant will hit the same gap again unless
101/// this doc comment (and the parallel test below) are kept in sync.
102fn classify_last_error_source(message: &str) -> &'static str {
103    if message.starts_with("no credentials for")
104        || message.starts_with("credential injection could not run")
105        || message.starts_with("this agent's")
106        // ReAgent P2 on PR #2854: SpawnGateError::ClaudeMdSeedFailed's own
107        // Display prefix — same regression this function's doc history
108        // already covers for AmbientHomeDirNotAllowed (codex P2, PR #2802),
109        // recurring for a newer SpawnGateError variant this function wasn't
110        // updated for when it was added.
111        || message.starts_with("could not isolate this agent's")
112    {
113        "identity"
114    } else if message.starts_with("container exec failed")
115        || message.starts_with("container ensure_running failed")
116        || message.starts_with("Couldn't start the container")
117    {
118        "container_spawn"
119    } else if message.starts_with("queued message could not be sent") {
120        "host_spawn"
121    } else {
122        "unknown"
123    }
124}
125
126/// Read the bounded tail of `block_id`'s `output` file and return the
127/// parsed [`LastErrorFrame`] if its last non-blank line is one. `None`
128/// covers every other case (no such block, empty file, last line is
129/// normal output, last line doesn't parse as this exact frame shape) —
130/// deliberately not distinguished further, since all of them mean the
131/// same thing to a caller: nothing actionable to surface here.
132fn last_error_frame(filestore: &FileStore, block_id: &str) -> Option<LastErrorFrame> {
133    let file = filestore.stat(block_id, "output").ok().flatten()?;
134    if file.size <= 0 {
135        return None;
136    }
137    let start = (file.size - LAST_ERROR_TAIL_BYTES).max(0);
138    let (_, bytes) = filestore
139        .read_at(block_id, "output", start, file.size - start)
140        .ok()?;
141    let text = String::from_utf8_lossy(&bytes);
142    let last_line = text.lines().rev().find(|l| !l.trim().is_empty())?;
143
144    let value: serde_json::Value = serde_json::from_str(last_line).ok()?;
145    if value.get("type").and_then(|v| v.as_str()) != Some("result")
146        || value.get("is_error").and_then(|v| v.as_bool()) != Some(true)
147        || value.get("subtype").and_then(|v| v.as_str()) != Some("error_during_execution")
148    {
149        return None;
150    }
151    let message = value.get("error")?.get("message")?.as_str()?.to_string();
152    let source = classify_last_error_source(message.strip_prefix("[AgentMux] ").unwrap_or(&message));
153
154    Some(LastErrorFrame {
155        message,
156        source,
157        written_ms: file.modts.max(0) as u64,
158    })
159}
160
161/// `GET /api/v1/muxspect/list` — every controller-backed block's current
162/// `ProcessStatus`, full detail (unlike `agent.tracked-blocks`, which
163/// intentionally returns only `block_ids` for its Swarm-pane contract).
164/// Each row also carries `is_agent` — `ProcessStatus::is_agent()`'s complete
165/// classification rule (subprocess/persistent/acp are ALWAYS agents
166/// regardless of `is_agent_pane`, which only applies to shell/cmd) — so
167/// consumers don't reimplement that rule themselves (codex P2 on PR #2380:
168/// the CLI's own naive `is_agent_pane`-only rendering mislabeled exactly
169/// those three controller types).
170pub async fn handle_muxspect_list(State(state): State<AppState>) -> impl IntoResponse {
171    let blocks: Vec<serde_json::Value> = state
172        .process_broker
173        .list()
174        .into_iter()
175        .map(|status| {
176            let is_agent = status.is_agent();
177            let last_error = last_error_frame(&state.filestore, &status.block_id);
178            let mut value = serde_json::to_value(&status).unwrap_or_default();
179            if let Some(obj) = value.as_object_mut() {
180                obj.insert("is_agent".to_string(), json!(is_agent));
181                obj.insert("last_error".to_string(), json!(last_error));
182            }
183            value
184        })
185        .collect();
186    Json(json!({ "blocks": blocks })).into_response()
187}
188
189#[derive(serde::Deserialize)]
190pub struct MuxspectDescribeQuery {
191    pub block_id: String,
192}
193
194/// `GET /api/v1/muxspect/describe?block_id=X` — composes `ProcessBroker`
195/// status, the coarse `BlockControllerRuntimeStatus`, and the OS-process
196/// tree for one block into a single response. This is the "describe
197/// everything about block X" query
198/// `REPORT_PROCESS_ARCHITECTURE_STATE_AND_RETHINK_2026_07_22.md` §5.4 named
199/// as missing — getting this picture today takes 2-3 separate, uncomposed
200/// RPC round-trips.
201pub async fn handle_muxspect_describe(
202    State(state): State<AppState>,
203    Query(q): Query<MuxspectDescribeQuery>,
204) -> impl IntoResponse {
205    if q.block_id.is_empty() {
206        return (
207            StatusCode::BAD_REQUEST,
208            Json(json!({ "error": "missing block_id" })),
209        )
210            .into_response();
211    }
212
213    // `process_status` (via `ProcessBroker::compute_status`) already reads
214    // BOTH the process-tracker registry (for `processes`/
215    // `liveness_confidence`) AND the controller's `BlockControllerRuntimeStatus`
216    // (carried on `process_status.controller_status`) in one pass. A second,
217    // independent read of either would risk a process starting/exiting or a
218    // turn starting/finishing between the two calls, returning two
219    // contradictory snapshots in one response (codex P2 on PR #2380, twice —
220    // once for the process list, once for the controller status). Derive
221    // everything from this one snapshot instead of reading twice.
222    let process_status = state.process_broker.status(&q.block_id);
223    let is_agent = process_status.is_agent();
224    // Read unconditionally, not gated on "no live controller" — cheap
225    // (bounded tail read), and gating it would mean re-deriving exactly
226    // the liveness logic `process_status` already computes just to decide
227    // whether to bother. In practice this is only ever non-null for
228    // exactly the case it exists for: nothing has appended to this
229    // block's output since an unrecovered pre-spawn failure.
230    let last_error = last_error_frame(&state.filestore, &q.block_id);
231
232    Json(json!({
233        "block_id": q.block_id,
234        "process_status": &process_status,
235        "is_agent": is_agent,
236        "controller_status": &process_status.controller_status,
237        "processes": &process_status.processes,
238        "tracking_confidence": process_status.liveness_confidence,
239        "last_error": last_error,
240    }))
241    .into_response()
242}
243
244#[derive(serde::Deserialize)]
245pub struct MuxspectFindQuery {
246    pub block_id: Option<String>,
247    pub agent: Option<String>,
248}
249
250/// `GET /api/v1/muxspect/find?block_id=X` or `?agent=X` — Ext 4 of
251/// `docs/reports/REPORT_MUXSPECT_MUXLOG_CROSS_CHANNEL_INSPECTION_2026_08_22.md`:
252/// "which running instance(s), if any, have a controller or subagent
253/// dispatch matching block_id/agent X" — the query that debugging session
254/// needed and had no tool for, ending in manual filesystem/env-var
255/// archaeology across every channel by hand.
256///
257/// Checks THIS instance first (host tier, no network) via the same
258/// `ProcessBroker::list()` `handle_muxspect_list` uses (so "found" here
259/// means the same thing `muxspect list` would show, not a different
260/// existence check invented for this endpoint) and, for an `agent` query,
261/// `reactive_handler.list_agents()` (same source `handle_muxspect_conversations`'s
262/// host tier and `verify-sender` use).
263///
264/// Then checks every OTHER channel via the shared reactive registry
265/// (`resolve_shared_reactive_dir` + `list_all_shared` — the exact mechanism
266/// `handle_muxspect_conversations`'s cross-channel tier already uses, see
267/// that handler's own doc comment for the security rationale) WITHOUT any
268/// network call at all for basic existence: `AgentEntry` already carries
269/// `block_id`/`agent_id`/`channel`/`local_url`, so a match there already
270/// answers "which channel" before any forwarding happens. Only a MATCHED
271/// cross-channel entry gets a forwarded `describe` call (same
272/// single-forwarded-hop / timeout discipline as the conversations handler)
273/// to fill in process/controller detail — unmatched channels cost nothing.
274///
275/// Always 200 — zero results is a legitimate answer ("not found on this
276/// host, in any known channel"), not an error, same posture as every other
277/// handler in this file. `results` can have more than one entry only if the
278/// same block_id/agent name somehow exists in more than one place at once
279/// (a real, worth-surfacing anomaly, not something to silently collapse to
280/// the first match).
281///
282/// **Cross-channel `block_id` scope (reagent P2 on PR #2745):** the
283/// cross-channel tier only searches the shared `AgentEntry` registry —
284/// agent-registered blocks. A plain shell/terminal block, or any other
285/// non-agent controller, in a DIFFERENT channel is not findable by
286/// `block_id` here (the host tier above has no such limit — it searches
287/// this instance's FULL `ProcessBroker::list()`, any controller type).
288/// Reaching a non-agent controller cross-channel would need a remote
289/// `/api/v1/muxspect/list`-style forward-and-filter, not just a registry
290/// lookup — out of scope for this change; see
291/// `SPEC_MUXSPECT_CROSS_INSTANCE_FIND_2026_08_22.md`'s Non-goals.
292///
293/// True when a forwarded `describe` response's `process_status.lifecycle`
294/// reads `"unknown"` — `broker::process::Lifecycle::Unknown`'s own doc
295/// comment: "no controller found for this block_id at all." A `None`
296/// input (the forward itself failed/timed out) is NOT the same as this —
297/// deliberately returns `false` for that case, since "we couldn't ask" is
298/// not the same claim as "we asked and it's confirmed gone." Pure
299/// (extracted from the async handler above) for direct unit testing —
300/// reagent P1 on PR #2745 found the un-extracted inline version reported
301/// "found": true unconditionally, with no test coverage of the "gone but
302/// still registered" case at all.
303fn describe_lifecycle_is_unknown(describe: Option<&serde_json::Value>) -> bool {
304    describe
305        .and_then(|d| d.get("process_status"))
306        .and_then(|ps| ps.get("lifecycle"))
307        .and_then(|l| l.as_str())
308        == Some("unknown")
309}
310
311pub async fn handle_muxspect_find(
312    State(state): State<AppState>,
313    Query(q): Query<MuxspectFindQuery>,
314) -> impl IntoResponse {
315    if q.block_id.as_deref().unwrap_or("").is_empty() && q.agent.as_deref().unwrap_or("").is_empty() {
316        return (
317            StatusCode::BAD_REQUEST,
318            Json(json!({ "error": "provide block_id or agent" })),
319        )
320            .into_response();
321    }
322
323    let mut results: Vec<serde_json::Value> = Vec::new();
324    let own_channel = std::env::var("AGENTMUX_CHANNEL").unwrap_or_else(|_| "stable".to_string());
325
326    // Host tier — no network, same source handle_muxspect_list/
327    // handle_muxspect_conversations already use. list_agents() (mutex lock +
328    // full clone of the agent map) is fetched ONCE here, not once per block
329    // inside the loop below — reagent P2 on PR #2745 caught the original
330    // version paying that cost per block, unnecessary repeated
331    // locking/cloning that scaled with block count, matching how
332    // handle_muxspect_conversations's own host tier already does it.
333    let agents = state.reactive_handler.list_agents();
334    for status in state.process_broker.list() {
335        let block_matches = q.block_id.as_deref().is_some_and(|b| b == status.block_id);
336        let agent_matches = q.agent.as_deref().is_some_and(|name| {
337            agents
338                .iter()
339                .any(|r| r.block_id == status.block_id && r.agent_id.eq_ignore_ascii_case(name))
340        });
341        if !block_matches && !agent_matches {
342            continue;
343        }
344        results.push(json!({
345            "tier": "host",
346            "channel": own_channel,
347            "block_id": status.block_id,
348            "found": true,
349            "process_status": &status,
350        }));
351    }
352
353    // Cross-channel tier — AgentEntry already carries block_id/agent_id, so
354    // matching costs zero network calls; only a match gets forwarded.
355    //
356    // reagent P1 on PR #2745: an earlier version unconditionally reported
357    // "found": true for any registry match, even a possibly-stale one whose
358    // owning agent/block already exited but hasn't been evicted from the
359    // shared registry yet — reported as a successful match with CLI exit
360    // code 0. Two checks now guard against that, mirroring the sibling
361    // handle_muxspect_verify_sender's staleness handling in this same file:
362    //   1. should_evict_on_forward_failure(&entry) (reused verbatim from
363    //      backend::reactive::registry — the exact function the registry's
364    //      OWN eviction logic uses, checking both real PID liveness and
365    //      FORWARD_FAILURE_GRACE_MS age) filters out entries already known
366    //      to be stale, before wasting a network call on them.
367    //   2. Even a fresh-looking entry can point at a block the remote
368    //      ProcessBroker no longer knows about (lifecycle: "unknown" — see
369    //      broker::process::Lifecycle's doc comment: "No controller found
370    //      for this block_id at all"). A successful forward with that
371    //      lifecycle is reported with "found": false, not true.
372    if let Some(shared_dir) = crate::registry::resolve_shared_reactive_dir() {
373        let local_url = state.local_web_url.clone();
374        let matches: Vec<_> = crate::backend::reactive::registry::list_all_shared(&shared_dir)
375            .into_iter()
376            .filter(|e| e.channel != own_channel && e.local_url != local_url)
377            .filter(|e| {
378                q.block_id.as_deref().is_some_and(|b| b == e.block_id)
379                    || q.agent.as_deref().is_some_and(|name| e.agent_id.eq_ignore_ascii_case(name))
380            })
381            .filter(|e| !crate::backend::reactive::registry::should_evict_on_forward_failure(e))
382            .collect();
383
384        let mut join_set = tokio::task::JoinSet::new();
385        for entry in matches {
386            let http_client = state.http_client.clone();
387            join_set.spawn(async move {
388                let fetch = http_client
389                    .get(format!("{}/api/v1/muxspect/describe", entry.local_url))
390                    .header(agentmux_common::AUTH_KEY_HEADER, &entry.auth_key)
391                    .query(&[("block_id", entry.block_id.as_str())])
392                    .send();
393
394                let describe = match tokio::time::timeout(
395                    std::time::Duration::from_millis(CROSS_CHANNEL_PREVIEW_TIMEOUT_MS),
396                    fetch,
397                )
398                .await
399                {
400                    Ok(Ok(resp)) if resp.status().is_success() => resp.json::<serde_json::Value>().await.ok(),
401                    _ => None, // timeout/connect/parse failure: still report the match, just without detail
402                };
403
404                let lifecycle_unknown = describe_lifecycle_is_unknown(describe.as_ref());
405
406                json!({
407                    "tier": "cross-channel",
408                    "channel": entry.channel,
409                    "block_id": entry.block_id,
410                    "agent_id": entry.agent_id,
411                    "found": !lifecycle_unknown,
412                    "describe": describe,
413                })
414            });
415        }
416        while let Some(res) = join_set.join_next().await {
417            if let Ok(value) = res {
418                results.push(value);
419            }
420        }
421    }
422
423    Json(json!({
424        "query": { "block_id": q.block_id, "agent": q.agent },
425        "results": results,
426    }))
427    .into_response()
428}
429
430/// A `ToolNode`'s status stays `"running"` this long (matches the
431/// frontend's own `TOOL_PROMOTION_MS`, `frontend/app/view/agent/activity/
432/// tool-adapter.ts`) before it's eligible to be flagged `stuck` here — a
433/// tool call that's merely taking a while must not be flagged the instant
434/// it starts. No shared constant exists between the two languages; keep
435/// this value in sync with the frontend's if either changes.
436const DOCK_STUCK_THRESHOLD_MS: i64 = 30_000;
437
438#[derive(serde::Deserialize)]
439pub struct MuxspectDockQuery {
440    pub block_id: String,
441}
442
443#[derive(serde::Serialize)]
444pub struct DockNodeView {
445    pub node_id: String,
446    pub tool_name: String,
447    pub status: String,
448    pub age_ms: i64,
449    /// `true` when `status == "running"`, past `DOCK_STUCK_THRESHOLD_MS`,
450    /// AND the block's own `ProcessBroker` lifecycle is not `Running` —
451    /// i.e. nothing srv-side is actively backing this block's turn at
452    /// all. Coarse: this is block-level, not node-level, liveness (a
453    /// block mid-turn on a LATER tool call would still read `Running` and
454    /// suppress the flag for an earlier stuck node in the same block) —
455    /// see spec §3.1's own framing of this as "the same signal a human
456    /// would manually cross-reference today, automated," not a perfect
457    /// per-node liveness oracle.
458    ///
459    /// Does NOT cover a backgrounded call stuck in the actual dock UI
460    /// (issue #2518): `status` here is the RAW `ToolNode` status, which is
461    /// terminal ("success") for an accepted background launch within ~a
462    /// second — this field can never go `true` for that case no matter how
463    /// long the real dock row has been showing `running`, because the
464    /// server has no visibility into whether a `<task-notification>` ever
465    /// arrived (that reclassification is entirely client-side, in
466    /// `tool-adapter.ts`). See `run_in_background` below: a `true` there
467    /// combined with a long `age_ms` is worth checking by hand even when
468    /// `stuck` reads `false`.
469    pub stuck: bool,
470    /// `params.run_in_background === true` on the pushing client's own
471    /// `ToolNode`, if it's a Bash call and the renderer reported it.
472    /// `None` for every non-Bash tool and for older clients that predate
473    /// this field. See `stuck`'s doc comment above for why this exists.
474    #[serde(skip_serializing_if = "Option::is_none")]
475    pub run_in_background: Option<bool>,
476}
477
478/// `GET /api/v1/muxspect/dock?block_id=X` — the cached `ToolNode` status
479/// snapshot for one block, cross-referenced against `ProcessBroker` to
480/// flag entries that look stuck. See
481/// `docs/specs/SPEC_MUXSPECT_DOCK_DIAGNOSIS_AND_REMEDIATION_2026_08_06.md` §3.1.
482pub async fn handle_muxspect_dock(
483    State(state): State<AppState>,
484    Query(q): Query<MuxspectDockQuery>,
485) -> impl IntoResponse {
486    if q.block_id.is_empty() {
487        return (
488            StatusCode::BAD_REQUEST,
489            Json(json!({ "error": "missing block_id" })),
490        )
491            .into_response();
492    }
493
494    let now_ms = agentmux_common::time::now_ms();
495
496    // One process_broker read, reused for every node — same rationale as
497    // handle_muxspect_describe: derive from one snapshot, not N reads that
498    // could observe N different, mutually-contradictory states.
499    let lifecycle = state.process_broker.status(&q.block_id).lifecycle;
500    let backed = lifecycle == crate::broker::process::Lifecycle::Running;
501
502    let nodes = dock_node_views(state.dock_snapshots.get(&q.block_id, now_ms), now_ms, backed);
503
504    // `DockSnapshotCache` is intentionally ephemeral (see its own doc
505    // comment) and evicts any entry — including a genuinely-still-running
506    // `bg: true` one — after MAX_NODE_AGE_MS (1 hour). A `task dev`
507    // session in this repo's own retros has run 12+ hours, so this CLI's
508    // output would go dark on it long before it actually finishes.
509    // `db_background_tasks` (Phase A/B) has no such eviction — merge in
510    // anything it still shows `Running` that the cache no longer has,
511    // rather than changing the cache's own eviction semantics (which are
512    // correct for what it's actually for — see dock_snapshot.rs's doc
513    // comment). See docs/specs/SPEC_BACKGROUND_TASK_DASHBOARD_INTELLIGENCE_2026_08_20.md §3.4.
514    let background_tasks = state.mstore.background_task_list_for_block(&q.block_id).unwrap_or_default();
515    let nodes = merge_background_tasks(nodes, background_tasks, now_ms);
516
517    Json(json!({ "block_id": q.block_id, "nodes": nodes })).into_response()
518}
519
520/// Append a synthetic `DockNodeView` for every `Running` `db_background_tasks`
521/// row not already represented in `nodes` (by id — `db_background_tasks.id`
522/// mirrors the dock's own `node_id`, see `background_tasks.rs`'s module doc
523/// comment). Pure, unit-testable like `dock_node_views` above. A task
524/// still present in `nodes` is left as-is — the live cache's own status is
525/// more specific (carries `tool_name`, real `stuck` computation) than
526/// anything this synthesizes from the registry alone.
527fn merge_background_tasks(
528    mut nodes: Vec<DockNodeView>,
529    background_tasks: Vec<crate::backend::storage::background_tasks::BackgroundTask>,
530    now_ms: i64,
531) -> Vec<DockNodeView> {
532    use std::collections::HashSet;
533    let existing: HashSet<String> = nodes.iter().map(|n| n.node_id.clone()).collect();
534    for task in background_tasks {
535        if task.status != crate::backend::storage::background_tasks::BackgroundTaskStatus::Running {
536            continue;
537        }
538        if existing.contains(&task.id) {
539            continue;
540        }
541        nodes.push(DockNodeView {
542            node_id: task.id,
543            tool_name: task.label,
544            status: "running".to_string(),
545            age_ms: (now_ms - task.started_at_ms).max(0),
546            // Not computed the same way as a live cache entry's `stuck`
547            // (no ProcessBroker cross-reference here, and a durable
548            // registry row surviving this long is expected, not
549            // suspicious, for a declared-background task) — false rather
550            // than guessing.
551            stuck: false,
552            run_in_background: Some(true),
553        });
554    }
555    nodes
556}
557
558/// Pure computation behind `handle_muxspect_dock` — separated out so the
559/// stuck-flagging logic (the actual diagnostic value of this endpoint) is
560/// unit-testable without constructing a full `AppState`/axum request, same
561/// discipline as `last_error_frame`/`classify_last_error_source` above.
562fn dock_node_views(
563    nodes: Vec<crate::backend::dock_snapshot::DockNodeSnapshot>,
564    now_ms: i64,
565    backed: bool,
566) -> Vec<DockNodeView> {
567    nodes
568        .into_iter()
569        .map(|n| {
570            let age_ms = (now_ms - n.observed_at).max(0);
571            let stuck = n.status == "running" && age_ms >= DOCK_STUCK_THRESHOLD_MS && !backed;
572            DockNodeView {
573                node_id: n.node_id,
574                tool_name: n.tool_name,
575                status: n.status,
576                age_ms,
577                stuck,
578                run_in_background: n.run_in_background,
579            }
580        })
581        .collect()
582}
583
584#[derive(serde::Deserialize, Default)]
585pub struct MuxspectBackgroundTasksQuery {
586    /// Omit to list every still-running declared-background task across
587    /// every block on this instance (the Swarm/fleet-view case); pass to
588    /// scope to one block's own history (including terminal tasks, unlike
589    /// the global list).
590    #[serde(default)]
591    pub block_id: Option<String>,
592}
593
594#[derive(serde::Serialize, ts_rs::TS)]
595#[ts(export, export_to = "../../../frontend/types/rpc/")]
596pub struct BackgroundTaskView {
597    pub id: String,
598    pub block_id: String,
599    pub label: String,
600    /// Always present, carries null when unknown -- no skip_serializing_if.
601    #[ts(type = "number | null")]
602    pub pid: Option<i64>,
603    #[ts(type = "number")]
604    pub started_at_ms: i64,
605    /// A closed set the frontend branches on; ts-rs would emit a bare
606    /// `string` for a `&'static str` and drop the union the hand-written
607    /// declaration carried.
608    #[ts(type = "\"running\" | \"done\" | \"error\" | \"stopped\"")]
609    pub status: &'static str,
610    #[ts(type = "number")]
611    pub last_seen_ms: i64,
612    #[ts(type = "number | null")]
613    pub ended_at_ms: Option<i64>,
614    /// The Agent call (`tool_use_id`) whose subagent launched this task —
615    /// the same id as that subagent's `tool_use_id` in Swarm. Null for the
616    /// agent's own tasks. Always present, like `pid`.
617    #[ts(type = "string | null")]
618    pub owner_tool_use_id: Option<String>,
619}
620
621impl From<crate::backend::storage::background_tasks::BackgroundTask> for BackgroundTaskView {
622    fn from(t: crate::backend::storage::background_tasks::BackgroundTask) -> Self {
623        BackgroundTaskView {
624            id: t.id,
625            block_id: t.block_id,
626            label: t.label,
627            pid: t.pid,
628            started_at_ms: t.started_at_ms,
629            status: t.status.as_str(),
630            last_seen_ms: t.last_seen_ms,
631            ended_at_ms: t.ended_at_ms,
632            owner_tool_use_id: t.owner_tool_use_id,
633        }
634    }
635}
636
637/// `GET /api/v1/muxspect/migrations` — the migration doctor for THIS
638/// instance's data dir: every registry migration with its applied/pending
639/// state and, for applied ones, the same post-condition verdict
640/// `agentmux-srv migrate --verify` prints
641/// (`SPEC_MIGRATION_SYSTEM_HARDENING_2026_08_03.md` Phase 1c). Built by the
642/// one producer both share (`migrations::doctor_report_for_instance`), so an
643/// operator inside a running instance gets exactly the answer the CLI would
644/// give against a stopped one — without hunting for the srv binary or the
645/// data dir. The incident the hardening spec was written about (§1.6: a
646/// migration "recorded as applied that wrote nothing") is exactly a "why did
647/// this agent fail to start" question, which is `muxspect`'s job.
648///
649/// Read-only, like `--verify`: opens both tracking stores with
650/// `SQLITE_OPEN_READ_ONLY`, never opens a `Store`, never creates a file.
651/// Runs on the blocking pool — it is a handful of SQLite reads, but they
652/// are synchronous. `exit_code` in the body is what `--verify` would exit
653/// with (0 or 3), so the CLI can mirror it.
654pub async fn handle_muxspect_migrations() -> impl IntoResponse {
655    let data_dir = crate::backend::base::get_mux_data_dir();
656    let report = tokio::task::spawn_blocking(move || crate::migrations::doctor_report_for_instance(&data_dir))
657        .await
658        .map_err(|e| format!("doctor task panicked: {e}"))
659        .and_then(|r| r);
660    match report {
661        Ok(report) => Json(report).into_response(),
662        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e }))).into_response(),
663    }
664}
665
666/// `GET /api/v1/muxspect/background-tasks[?block_id=X]` — the durable
667/// declared-background task registry (`db_background_tasks`), the source
668/// of truth `handle_muxspect_dock`'s ephemeral, 1-hour-evicted
669/// `DockSnapshotCache` deliberately isn't (see
670/// docs/status/STATUS_ATTACHED_TASK_AXIS_AND_DEV_LOOP_2026_08_15.md). A
671/// task still shows up here after the dock cache has evicted it, and
672/// survives a pane reload — it's backed by SQLite, not an in-memory cache.
673pub async fn handle_muxspect_background_tasks(
674    State(state): State<AppState>,
675    Query(q): Query<MuxspectBackgroundTasksQuery>,
676) -> impl IntoResponse {
677    let result = match q.block_id.as_deref() {
678        Some(block_id) if !block_id.is_empty() => state.mstore.background_task_list_for_block(block_id),
679        _ => state.mstore.background_task_list_running(),
680    };
681    match result {
682        Ok(tasks) => {
683            let views: Vec<BackgroundTaskView> = tasks.into_iter().map(Into::into).collect();
684            Json(json!({ "tasks": views })).into_response()
685        }
686        Err(e) => (
687            StatusCode::INTERNAL_SERVER_ERROR,
688            Json(json!({ "error": e.to_string() })),
689        )
690            .into_response(),
691    }
692}
693
694#[derive(serde::Deserialize)]
695pub struct MuxspectDockClearRequest {
696    pub block_id: String,
697    pub node_id: String,
698}
699
700/// `POST /api/v1/muxspect/dock/clear` — validates `node_id` is a currently
701/// tracked node for `block_id` (per the cache — see
702/// `docs/specs/SPEC_MUXSPECT_DOCK_DIAGNOSIS_AND_REMEDIATION_2026_08_06.md`
703/// §3.2), then publishes `EVENT_DOCK_CLEAR` scoped to `block:<id>`. Only a
704/// renderer currently displaying that block and still holding that node
705/// ever receives or acts on it (real server-side scope routing, not
706/// client-side self-filtering — same mechanism `EVENT_SHELL_NODE_CREATE`
707/// already uses). Returns 404 if the node isn't in the cache — either it
708/// was never pushed, or it already resolved/was already cleared; either
709/// way there's nothing to do.
710pub async fn handle_muxspect_dock_clear(
711    State(state): State<AppState>,
712    Json(req): Json<MuxspectDockClearRequest>,
713) -> impl IntoResponse {
714    if req.block_id.is_empty() || req.node_id.is_empty() {
715        return (
716            StatusCode::BAD_REQUEST,
717            Json(json!({ "error": "missing block_id or node_id" })),
718        )
719            .into_response();
720    }
721
722    if !state.dock_snapshots.has_node(&req.block_id, &req.node_id) {
723        return (
724            StatusCode::NOT_FOUND,
725            Json(json!({ "error": "no such node in this block's dock snapshot", "cleared": false })),
726        )
727            .into_response();
728    }
729
730    state.broker.publish_event::<crate::backend::mps_events::DockClear>(&crate::backend::mps_events::DockClearEvent { node_id: req.node_id.clone() }, vec![format!("block:{}", req.block_id)], 0);
731    state.dock_snapshots.remove_node(&req.block_id, &req.node_id);
732
733    Json(json!({ "cleared": true, "block_id": req.block_id, "node_id": req.node_id })).into_response()
734}
735
736#[derive(serde::Deserialize)]
737pub struct MuxspectVerifySenderQuery {
738    pub name: String,
739}
740
741/// **Not a security/trust primitive.** This reports registry LIVENESS only
742/// (does an agent named X currently exist in the discovery data at all) —
743/// it performs NO cryptographic check and must not be confused with the
744/// actual JEKT sender-authentication mechanism, which is per-message,
745/// automatic, and already computes a real `TRUST=`/`SIG=` value on every
746/// delivered jekt via HMAC-SHA256 (host tier) / Ed25519 (LAN tier) / a
747/// pinned Ed25519 key (reagent WAN) — see this repo's root `CLAUDE.md`,
748/// "Is a jekt's sender identity actually verified? — the real answer".
749/// Earlier drafts of this route reused that exact vocabulary
750/// (`host-verified`/`network-claimed`) for a mere presence check, which
751/// collided with — and could be mistaken for — that real, stronger
752/// guarantee (reagentx-workflow review on PR #2702, P1). Deliberately no
753/// `trust`/`verified` field here at all: `status` + `tier` fully describe
754/// what was actually checked (presence, not authenticity), with nothing
755/// that could be misread as more than that.
756#[derive(serde::Serialize, Debug, PartialEq, Eq)]
757pub struct VerifySenderVerdict {
758    pub name: String,
759    /// `"found"` | `"not_found"` | `"stale"`.
760    pub status: &'static str,
761    #[serde(skip_serializing_if = "Option::is_none")]
762    pub tier: Option<&'static str>,
763    #[serde(skip_serializing_if = "Option::is_none")]
764    pub last_seen_ms: Option<i64>,
765    #[serde(skip_serializing_if = "Option::is_none")]
766    pub channel: Option<String>,
767    #[serde(skip_serializing_if = "Option::is_none")]
768    pub local_url: Option<String>,
769}
770
771/// One already-fetched candidate, tagged with the tier it was found on.
772/// Keeps [`classify_sender`] pure and independent of `AgentRegistration`/
773/// `AgentEntry`/`LanInstance`'s actual (differently-shaped) types — the
774/// handler below does the field mapping once, at the IO boundary, same
775/// discipline as `dock_node_views`/`last_error_frame` above.
776struct SenderCandidate {
777    name: String,
778    /// `"host"` | `"cross-channel"` | `"lan"` | `"wan"` — NOT `"spawner"`;
779    /// that tier is checked entirely client-side by `muxspect.mjs` before
780    /// this route is ever called (it depends on the CALLER's own
781    /// environment, which this handler has no way to observe). See
782    /// `docs/specs/SPEC_MUXSPECT_VERIFY_SENDER_2026_08_21.md`.
783    tier: &'static str,
784    last_seen_ms: Option<i64>,
785    /// Whether `last_seen_ms` is a genuinely-refreshed heartbeat this
786    /// candidate's tier can be judged stale by. `false` for host tier —
787    /// `ReactiveHandler::list_agents()` is synchronously accurate (an
788    /// agent is removed from it on unregister, not aged out by timestamp;
789    /// see `bootstrap/network.rs`'s 20s heartbeat task's own doc comment:
790    /// "always accurate, with no staleness window of its own") and nothing in
791    /// this codebase currently calls `update_last_seen` to refresh
792    /// `AgentRegistration.last_seen` after registration — applying a
793    /// staleness cutoff to it would eventually flag every healthy,
794    /// long-running host-tier agent as stale (#2702). `false` for WAN too (`last_seen_ms` is always `None` there —
795    /// `cloud_subscriber` doesn't track a per-agent heartbeat). `true` for
796    /// cross-channel (the same 20s heartbeat task above re-writes the
797    /// shared registry's `updated_at` for every live host-tier agent) and
798    /// LAN (mDNS peers re-announce and bump `last_seen` on every
799    /// `ServiceResolved`).
800    stale_eligible: bool,
801    channel: Option<String>,
802    local_url: Option<String>,
803}
804
805/// Pure verdict computation over already-fetched discovery data — see
806/// [`handle_muxspect_verify_sender`] for the IO/fetch boundary this
807/// composes with. `candidates` must already be in tier-priority order
808/// (host, then cross-channel, then lan, then wan — most-trusted first).
809///
810/// Among all case-insensitive name matches (a name can legitimately appear
811/// on more than one tier, or more than once within cross-channel if
812/// several channels registered it), prefers the first NON-stale match in
813/// tier-priority order; only falls back to a stale match if every match is
814/// stale. Picking the literal first match regardless of staleness (an
815/// earlier version of this function) could report `stale` even when a
816/// later, live entry for the same sender existed (codex review on PR
817/// #2702, P2).
818fn classify_sender(name: &str, candidates: &[SenderCandidate], now_ms: i64) -> VerifySenderVerdict {
819    let needle = name.to_lowercase();
820    let is_stale = |c: &&SenderCandidate| {
821        c.stale_eligible
822            && c.last_seen_ms
823                .is_some_and(|ls| now_ms - ls >= crate::backend::reactive::registry::FORWARD_FAILURE_GRACE_MS as i64)
824    };
825    let matches: Vec<&SenderCandidate> = candidates.iter().filter(|c| c.name.to_lowercase() == needle).collect();
826    let chosen = matches.iter().find(|c| !is_stale(c)).or_else(|| matches.first());
827
828    match chosen {
829        None => VerifySenderVerdict {
830            name: name.to_string(),
831            status: "not_found",
832            tier: None,
833            last_seen_ms: None,
834            channel: None,
835            local_url: None,
836        },
837        Some(c) => VerifySenderVerdict {
838            name: name.to_string(),
839            status: if is_stale(c) { "stale" } else { "found" },
840            tier: Some(c.tier),
841            last_seen_ms: c.last_seen_ms,
842            channel: c.channel.clone(),
843            local_url: c.local_url.clone(),
844        },
845    }
846}
847
848/// `GET /api/v1/muxspect/verify-sender?name=X` — answers "is an agent named
849/// X currently registered, and via what tier" in one call, so an agent
850/// that just received a `[JEKT:FROM=X ...]` marker doesn't have to fall
851/// back to manual filesystem/process archaeology to sanity-check the
852/// claimed sender exists at all. See [`VerifySenderVerdict`]'s doc comment
853/// for the important caveat this is NOT the JEKT protocol's own
854/// cryptographic sender verification. See `docs/retro/RETRO_JEKT_CROSS_
855/// CHANNEL_TRUST_SELF_DECLARED_2026_08_21.md` for the incident this closes
856/// the liveness-lookup gap on, and `docs/specs/SPEC_MUXSPECT_VERIFY_
857/// SENDER_2026_08_21.md` for the full design.
858///
859/// Composes the same four sources `handle_discovery` (`GET
860/// /agentmux/discovery`) already aggregates — host-tier
861/// `AgentRegistration`, the host-global cross-channel shared registry, LAN
862/// mDNS peers, and the WAN cloud subscriber — into a verdict instead of
863/// raw discovery data. Always 200 (matching `list`/`dock`/`describe`'s own
864/// convention of encoding "nothing found" in the body rather than the HTTP
865/// status — a `not_found` verdict is a legitimate query result, not a
866/// caller error) — `apiGet`'s fail-on-non-2xx contract in `muxspect.mjs`
867/// would otherwise turn a normal "no such sender" answer into a hard CLI
868/// failure.
869pub async fn handle_muxspect_verify_sender(
870    State(state): State<AppState>,
871    Query(q): Query<MuxspectVerifySenderQuery>,
872) -> impl IntoResponse {
873    if q.name.is_empty() {
874        return (StatusCode::BAD_REQUEST, Json(json!({ "error": "missing name" }))).into_response();
875    }
876
877    let mut candidates: Vec<SenderCandidate> = Vec::new();
878
879    candidates.extend(state.reactive_handler.list_agents().into_iter().map(|a| SenderCandidate {
880        name: a.agent_id,
881        tier: "host",
882        last_seen_ms: Some(a.last_seen as i64),
883        stale_eligible: false,
884        channel: None,
885        local_url: None,
886    }));
887
888    // Mirrors handle_discovery's own cross_channel derivation exactly
889    // (same exclusion of this instance's own channel/URL) — kept as a
890    // separate read here rather than refactored into a shared helper, to
891    // avoid touching handle_discovery's existing, already-tested behavior
892    // for an unrelated new route.
893    let own_channel = std::env::var("AGENTMUX_CHANNEL").unwrap_or_else(|_| "stable".to_string());
894    let local_url = state.local_web_url.clone();
895    if let Some(shared_dir) = crate::registry::resolve_shared_reactive_dir() {
896        candidates.extend(
897            crate::backend::reactive::registry::list_all_shared(&shared_dir)
898                .into_iter()
899                .filter(|e| e.channel != own_channel && e.local_url != local_url)
900                .map(|e| SenderCandidate {
901                    name: e.agent_id,
902                    tier: "cross-channel",
903                    last_seen_ms: Some(e.updated_at as i64),
904                    stale_eligible: true,
905                    channel: Some(e.channel),
906                    local_url: Some(e.local_url),
907                }),
908        );
909    }
910
911    for lan_instance in state.lan_discovery.get_instances() {
912        let peer_url = format!("{}:{}", lan_instance.address, lan_instance.port);
913        // LanInstance.last_seen is UNIX SECONDS (lan_discovery.rs stamps it
914        // via `.as_secs()`), not milliseconds like every other timestamp in
915        // this handler — multiplying is required or every LAN candidate
916        // reads as ~1970 and is immediately (wrongly) flagged stale (codex
917        // review on PR #2702, P1).
918        candidates.extend(lan_instance.agents.iter().map(|agent_name| SenderCandidate {
919            name: agent_name.clone(),
920            tier: "lan",
921            last_seen_ms: Some((lan_instance.last_seen as i64).saturating_mul(1000)),
922            stale_eligible: true,
923            channel: None,
924            local_url: Some(peer_url.clone()),
925        }));
926    }
927
928    if let Some(subscriber) = crate::muxbus::cloud_subscriber::get_global_subscriber() {
929        candidates.extend(subscriber.subscribed_agents().into_iter().map(|agent_name| SenderCandidate {
930            name: agent_name,
931            tier: "wan",
932            last_seen_ms: None,
933            stale_eligible: false,
934            channel: None,
935            local_url: None,
936        }));
937    }
938
939    let now_ms = agentmux_common::time::now_ms();
940
941    Json(classify_sender(&q.name, &candidates, now_ms)).into_response()
942}
943
944/// Truncation length for a `last_message_preview` — keeps `conversations`'
945/// response small even when the tail line is a long tool-call/result frame;
946/// this is a liveness-glance preview, not a transcript reader (that's
947/// `GetAgentTranscript`/`muxspect conversation <agent>`'s job). Applied to
948/// BOTH the host-tier read ([`last_line_preview_and_activity`]) and the
949/// cross-channel forwarded read — codex P2 on PR #2715 caught the
950/// cross-channel path skipping this entirely, which let one huge remote
951/// tool-call/result line inflate an otherwise-bounded response.
952const PREVIEW_MAX_CHARS: usize = 200;
953
954/// Bounded tail window read directly off the live FileStore file in
955/// [`last_line_preview_and_activity`]'s fast path — deliberately small
956/// relative to a whole session (which can be many MB): only needs to
957/// comfortably contain the single last non-blank line, not search for a
958/// specific frame shape the way [`LAST_ERROR_TAIL_BYTES`]'s window does.
959const PREVIEW_TAIL_BYTES: i64 = 4096;
960
961/// Short per-forward timeout for cross-channel preview fetches in
962/// [`handle_muxspect_conversations`] — a single dead/unresponsive channel
963/// must not stall the whole listing; best-effort, matches this file's
964/// diagnostic (never authoritative) posture elsewhere.
965const CROSS_CHANNEL_PREVIEW_TIMEOUT_MS: u64 = 1500;
966
967/// Truncate a preview line to [`PREVIEW_MAX_CHARS`], appending an ellipsis
968/// when it was actually cut. Shared by the host-tier and cross-channel
969/// preview paths so they can't drift out of sync again (codex P2 on
970/// PR #2715 — the cross-channel path originally didn't call this at all).
971fn truncate_preview(line: &str) -> String {
972    if line.chars().count() > PREVIEW_MAX_CHARS {
973        line.chars().take(PREVIEW_MAX_CHARS).collect::<String>() + "…"
974    } else {
975        line.to_string()
976    }
977}
978
979/// Last non-blank line of a block's own transcript, truncated for preview
980/// display, plus a real "when was this last written" timestamp — `None`
981/// for either if the block has no session output yet (e.g. an agent
982/// registered but no turn has produced output) — missing is not an error,
983/// same "encode absence in the body, not the status" posture as the rest
984/// of this file.
985///
986/// Fast path: a bounded tail read straight off the live FileStore file
987/// (`stat` + a small `read_at`, not a full read) — covers the overwhelming
988/// common case (an active or recently-active, non-archived block) without
989/// loading/decompressing full session history just to preview one line
990/// (codex P2 on PR #2715: the original version called
991/// `session_archive::read_session_output`, a full-session-export
992/// primitive, for every host agent on every `conversations` call). The
993/// file's own `modts` doubles as a REAL last-activity timestamp — unlike
994/// `AgentRegistration::last_seen`, which nothing in this codebase updates
995/// after registration (codex P2 on PR #2715: using it as
996/// `last_activity_ms` made a long-lived, actively-working agent look
997/// hours-stale). Falls back to the full-read primitive (which also
998/// handles archived/gzip sessions) only when the fast path finds nothing —
999/// no live file yet, or genuinely archived — at the cost of no cheap
1000/// activity timestamp in that fallback case (`None`, not a guessed value).
1001fn last_line_preview_and_activity(
1002    mstore: &std::sync::Arc<crate::backend::storage::store::Store>,
1003    filestore: &std::sync::Arc<FileStore>,
1004    block_id: &str,
1005) -> (Option<String>, Option<u64>) {
1006    if let Ok(Some(file)) = filestore.stat(block_id, "output") {
1007        if file.size > 0 {
1008            let start = (file.size - PREVIEW_TAIL_BYTES).max(0);
1009            if let Ok((_, bytes)) = filestore.read_at(block_id, "output", start, file.size - start) {
1010                let text = String::from_utf8_lossy(&bytes);
1011                if let Some(last) = text.lines().rev().find(|l| !l.trim().is_empty()) {
1012                    return (Some(truncate_preview(last)), Some(file.modts.max(0) as u64));
1013                }
1014            }
1015        }
1016    }
1017
1018    match crate::backend::session_archive::read_session_output(mstore, filestore, block_id) {
1019        Ok((raw_bytes, _)) => {
1020            let text = String::from_utf8_lossy(&raw_bytes);
1021            let preview = text
1022                .lines()
1023                .rev()
1024                .find(|l| !l.trim().is_empty())
1025                .map(truncate_preview);
1026            (preview, None)
1027        }
1028        Err(_) => (None, None),
1029    }
1030}
1031
1032/// `GET /api/v1/muxspect/conversations` — a single-call, all-tier glance at
1033/// every agent's most recent activity, so an agent (or a human via `muxspect
1034/// conversations`) doesn't have to compose `DiscoverAgents` +
1035/// N×`GetAgentTranscript` calls by hand. See
1036/// `docs/specs/SPEC_MUXSPECT_CROSS_TIER_CONVERSATION_VISIBILITY_2026_08_21.md`
1037/// Phase A.
1038///
1039/// Host and cross-channel entries carry a `last_message_preview` (the tail
1040/// non-blank transcript line) and `turn_active`, read directly for host
1041/// (this instance's own `mstore`/`filestore`) and via a single best-effort
1042/// forwarded HTTP call per channel for cross-channel (same auth/loopback
1043/// pattern `handle_reactive_inject`'s Tier 2b and this instance's own
1044/// `handle_muxspect_verify_sender` already use — see those for the security
1045/// rationale; not repeated here). LAN and WAN entries carry no preview
1046/// (`remote_fetch_required: true`) — Phase A deliberately does not invent a
1047/// remote-read protocol for those tiers; see the spec's Phase B/C.
1048///
1049/// Always 200 — an empty `agents` list is a legitimate result, not an
1050/// error, matching every other handler in this file.
1051pub async fn handle_muxspect_conversations(State(state): State<AppState>) -> impl IntoResponse {
1052    let mut agents: Vec<serde_json::Value> = Vec::new();
1053    let work_facts = super::work_facts_handlers::start_conversation_facts(&state); // beside the previews below
1054
1055    // Host tier — direct local read, no network.
1056    for reg in state.reactive_handler.list_agents() {
1057        let (preview, activity_ms) =
1058            last_line_preview_and_activity(&state.mstore, &state.filestore, &reg.block_id);
1059        let turn_active = crate::backend::blockcontroller::get_block_controller_status(&reg.block_id)
1060            .map(|s| s.turn_active)
1061            .unwrap_or(false);
1062        agents.push(json!({
1063            "name": reg.agent_id,
1064            "tier": "host",
1065            "block_id": reg.block_id,
1066            "turn_active": turn_active,
1067            // Transcript-write time (see last_line_preview_and_activity);
1068            // registration time only while there is no output at all.
1069            "last_activity_ms": activity_ms.unwrap_or(reg.last_seen),
1070            "last_message_preview": preview,
1071        }));
1072    }
1073
1074    // Cross-channel tier — one best-effort forwarded call per channel, ALL
1075    // concurrent (codex P2 on PR #2715: sequential fetches meant a single
1076    // dead/slow channel added its full CROSS_CHANNEL_PREVIEW_TIMEOUT_MS to
1077    // TOTAL latency instead of every channel paying it once, in parallel).
1078    // `forwarded=true` caps each of these at the same single-hop guard
1079    // `handle_reactive_transcript`'s own cross-channel fallback uses (see
1080    // TranscriptQuery::forwarded's doc comment) — this call IS a forward,
1081    // from the target instance's point of view, exactly like that one.
1082    let own_channel = std::env::var("AGENTMUX_CHANNEL").unwrap_or_else(|_| "stable".to_string());
1083    let local_url = state.local_web_url.clone();
1084    if let Some(shared_dir) = crate::registry::resolve_shared_reactive_dir() {
1085        let cross_channel_entries: Vec<_> =
1086            crate::backend::reactive::registry::list_all_shared(&shared_dir)
1087                .into_iter()
1088                .filter(|e| e.channel != own_channel && e.local_url != local_url)
1089                .collect();
1090
1091        let mut join_set = tokio::task::JoinSet::new();
1092        for entry in cross_channel_entries {
1093            let http_client = state.http_client.clone();
1094            join_set.spawn(async move {
1095                let mut preview: Option<String> = None;
1096                let mut turn_active: Option<bool> = None;
1097
1098                let fetch = http_client
1099                    .get(format!("{}/agentmux/reactive/transcript", entry.local_url))
1100                    .header(agentmux_common::AUTH_KEY_HEADER, &entry.auth_key)
1101                    .query(&[
1102                        ("agent", entry.agent_id.as_str()),
1103                        ("max_lines", "1"),
1104                        ("forwarded", "true"),
1105                    ])
1106                    .send();
1107
1108                if let Ok(Ok(resp)) = tokio::time::timeout(
1109                    std::time::Duration::from_millis(CROSS_CHANNEL_PREVIEW_TIMEOUT_MS),
1110                    fetch,
1111                )
1112                .await
1113                {
1114                    if resp.status().is_success() {
1115                        if let Ok(body) = resp.json::<serde_json::Value>().await {
1116                            turn_active = body.get("turn_active").and_then(|v| v.as_bool());
1117                            preview = body
1118                                .get("lines")
1119                                .and_then(|v| v.as_array())
1120                                .and_then(|arr| arr.last())
1121                                .and_then(|v| v.as_str())
1122                                .map(truncate_preview);
1123                        }
1124                    }
1125                }
1126                // Timeout/connection/parse failure: list the agent anyway
1127                // with no preview — best-effort, same as every other tier
1128                // here.
1129
1130                json!({
1131                    "name": entry.agent_id,
1132                    "tier": "cross-channel",
1133                    "channel": entry.channel,
1134                    "block_id": entry.block_id,
1135                    "turn_active": turn_active,
1136                    "last_activity_ms": entry.updated_at,
1137                    "last_message_preview": preview,
1138                })
1139            });
1140        }
1141        while let Some(res) = join_set.join_next().await {
1142            // A panicked/cancelled task just doesn't contribute a row —
1143            // best-effort, matching this whole tier's posture elsewhere.
1144            if let Ok(value) = res {
1145                agents.push(value);
1146            }
1147        }
1148    }
1149
1150    // LAN tier — liveness only, no remote-read protocol in Phase A.
1151    for lan_instance in state.lan_discovery.get_instances() {
1152        for agent_name in &lan_instance.agents {
1153            agents.push(json!({
1154                "name": agent_name,
1155                "tier": "lan",
1156                "host": format!("{}:{}", lan_instance.address, lan_instance.port),
1157                "remote_fetch_required": true,
1158            }));
1159        }
1160    }
1161
1162    // WAN tier — liveness only, no remote-read protocol in Phase A.
1163    if let Some(subscriber) = crate::muxbus::cloud_subscriber::get_global_subscriber() {
1164        for agent_name in subscriber.subscribed_agents() {
1165            agents.push(json!({
1166                "name": agent_name,
1167                "tier": "wan",
1168                "remote_fetch_required": true,
1169            }));
1170        }
1171    }
1172
1173    super::work_facts_handlers::annotate_conversations(work_facts, &mut agents).await; // their `work`
1174    Json(json!({ "agents": agents })).into_response()
1175}
1176
1177/// Flatten one layout tree into a compact, renderable node list.
1178///
1179/// Deliberately a FLAT list carrying a `depth`, not nested JSON: the CLI
1180/// renders an indented outline, and a flat list keeps both the wire shape and
1181/// the renderer trivial.
1182fn flatten_layout_nodes(
1183    node: &agentmux_common::LayoutNode,
1184    depth: usize,
1185    index_path: &str,
1186    out: &mut Vec<serde_json::Value>,
1187) {
1188    let is_branch = !node.children.is_empty();
1189    out.push(json!({
1190        "id": node.id,
1191        "depth": depth,
1192        "path": index_path,
1193        "kind": if is_branch { "branch" } else { "leaf" },
1194        "flex_direction": match node.flex_direction {
1195            agentmux_common::FlexDirection::Row => "row",
1196            agentmux_common::FlexDirection::Column => "column",
1197        },
1198        "size": node.size,
1199        // `minimized` round-trips through the untyped `extra` catch-all on
1200        // this side, not as a typed field — same access the reducer's own
1201        // `is_node_locked` uses. Reported as "locked" rather than raw
1202        // `minimized` because legacy pre-display-mode markers
1203        // (`minimizedSize`/`slipMinimize`/`columnDissolve`) count too, and an
1204        // unmigrated tree on disk is exactly the case this route should show.
1205        "locked": crate::backend::layout::is_node_locked(node),
1206        // A branch is "effectively minimized" when every leaf under it is —
1207        // the state that drives chip geometry. Surfaced explicitly because it
1208        // is NOT visible from the `minimized` flag alone on a branch.
1209        "effectively_minimized": crate::backend::layout::is_effectively_minimized(node),
1210        "block_id": node.data.as_ref().map(|d| d.block_id.clone()),
1211        "child_count": node.children.len(),
1212    }));
1213    for (i, child) in node.children.iter().enumerate() {
1214        let child_path = if index_path.is_empty() {
1215            i.to_string()
1216        } else {
1217            format!("{index_path}.{i}")
1218        };
1219        flatten_layout_nodes(child, depth + 1, &child_path, out);
1220    }
1221}
1222
1223/// `GET /api/v1/muxspect/layout[?tab_id=X]` — the persisted layout tree for
1224/// every tab (or one), plus the layout doctor's verdict on each.
1225///
1226/// Why this exists: layout state was previously only inspectable by reading
1227/// `db_layout` out of SQLite by hand and walking the JSON, which is what
1228/// diagnosing the cross-split minimize bugs (#2848, #2850, #2855) actually
1229/// required. This applies `muxspect`'s existing "what is this instance doing
1230/// right now" contract to layout.
1231///
1232/// It reports the PERSISTED tree, a genuinely different question from what
1233/// the frontend currently renders: `validate_layout_invariants` normally runs
1234/// at reducer write-time, so running it on demand here also catches
1235/// corruption already sitting on disk, including trees written by older
1236/// versions that never ran the check.
1237///
1238/// Read-only, like every route in this module except `dock clear`.
1239pub async fn handle_muxspect_layout(
1240    State(state): State<AppState>,
1241    Query(q): Query<MuxspectLayoutQuery>,
1242) -> impl IntoResponse {
1243    let tabs = match state.mstore.get_all::<crate::backend::obj::Tab>() {
1244        Ok(t) => t,
1245        Err(e) => {
1246            // 200 with an `error` field, not a 4xx/5xx — muxspect.mjs's
1247            // apiGet() treats a non-2xx as a transport failure and prints a
1248            // connection error, which would misattribute a store-read problem
1249            // as "srv is unreachable".
1250            return Json(json!({ "error": format!("failed to list tabs: {e}") })).into_response();
1251        }
1252    };
1253
1254    let mut layouts = Vec::new();
1255    for tab in tabs {
1256        if let Some(want) = q.tab_id.as_deref() {
1257            if tab.oid != want {
1258                continue;
1259            }
1260        }
1261        let ls = match state
1262            .mstore
1263            .must_get::<crate::backend::obj::LayoutState>(&tab.layoutstate)
1264        {
1265            Ok(ls) => ls,
1266            Err(e) => {
1267                // A tab pointing at a missing layoutstate is itself a finding
1268                // worth surfacing rather than skipping silently.
1269                layouts.push(json!({
1270                    "tab_id": tab.oid,
1271                    "tab_name": tab.name,
1272                    "layoutstate_oid": tab.layoutstate,
1273                    "error": format!("layoutstate unreadable: {e}"),
1274                }));
1275                continue;
1276            }
1277        };
1278
1279        let violations = crate::backend::layout::validate_layout_invariants(&ls.rootnode);
1280        let mut nodes = Vec::new();
1281        if let Some(root) = ls.rootnode.as_ref() {
1282            flatten_layout_nodes(root, 0, "", &mut nodes);
1283        }
1284        let leaf_count = nodes.iter().filter(|n| n["kind"] == "leaf").count();
1285        let minimized_leaves = nodes
1286            .iter()
1287            .filter(|n| n["kind"] == "leaf" && n["locked"] == true)
1288            .count();
1289
1290        layouts.push(json!({
1291            "tab_id": tab.oid,
1292            "tab_name": tab.name,
1293            "layoutstate_oid": ls.oid,
1294            "magnified_node_id": ls.magnifiednodeid,
1295            "node_count": nodes.len(),
1296            "leaf_count": leaf_count,
1297            "minimized_leaf_count": minimized_leaves,
1298            "violations": violations,
1299            "healthy": violations.is_empty(),
1300            "nodes": nodes,
1301        }));
1302    }
1303
1304    // An explicit tab_id that matched nothing is a FAILED LOOKUP, not an
1305    // empty result. Returning `{"layouts": []}` made a stale or mistyped id
1306    // indistinguishable from a successful run over a tab with no panes --
1307    // the CLI printed "no layouts found" and exited 0, which is exactly the
1308    // wrong answer for a targeted diagnostic in a script (codex P2 on
1309    // PR #2856). Same 200-with-`error` shape as the store-read failure above,
1310    // for the same reason: a non-2xx would be reported by muxspect.mjs's
1311    // apiGet() as "srv is unreachable".
1312    if let Some(want) = q.tab_id.as_deref() {
1313        if layouts.is_empty() {
1314            return Json(json!({ "error": format!("no tab with id '{want}' in this instance") }))
1315                .into_response();
1316        }
1317    }
1318
1319    Json(json!({ "layouts": layouts })).into_response()
1320}
1321
1322#[derive(serde::Deserialize)]
1323pub struct MuxspectLayoutQuery {
1324    /// Restrict to one tab. Omitted = every tab in this instance.
1325    pub tab_id: Option<String>,
1326}
1327
1328#[cfg(test)]
1329mod tests {
1330    use super::*;
1331    use crate::backend::storage::filestore::{FileMeta, FileOpts};
1332
1333    fn error_frame_line(message: &str) -> String {
1334        json!({
1335            "type": "result",
1336            "is_error": true,
1337            "subtype": "error_during_execution",
1338            "error": {"message": message}
1339        })
1340        .to_string()
1341    }
1342
1343    // reagent P1 on PR #2745 — pins describe_lifecycle_is_unknown, the
1344    // pure piece extracted from handle_muxspect_find's cross-channel
1345    // staleness check.
1346    #[test]
1347    fn describe_lifecycle_is_unknown_true_for_lifecycle_unknown() {
1348        let describe = json!({ "process_status": { "lifecycle": "unknown" } });
1349        assert!(describe_lifecycle_is_unknown(Some(&describe)));
1350    }
1351
1352    #[test]
1353    fn describe_lifecycle_is_unknown_false_for_a_real_lifecycle() {
1354        for lifecycle in ["running", "idle", "done", "error"] {
1355            let describe = json!({ "process_status": { "lifecycle": lifecycle } });
1356            assert!(!describe_lifecycle_is_unknown(Some(&describe)), "lifecycle={lifecycle}");
1357        }
1358    }
1359
1360    #[test]
1361    fn describe_lifecycle_is_unknown_false_when_the_forward_itself_failed() {
1362        // None (the describe call timed out/errored) is NOT the same claim
1363        // as "confirmed gone" — "we couldn't ask" must not read as "unknown".
1364        assert!(!describe_lifecycle_is_unknown(None));
1365    }
1366
1367    #[test]
1368    fn describe_lifecycle_is_unknown_false_for_malformed_or_missing_fields() {
1369        assert!(!describe_lifecycle_is_unknown(Some(&json!({}))));
1370        assert!(!describe_lifecycle_is_unknown(Some(&json!({ "process_status": {} }))));
1371        assert!(!describe_lifecycle_is_unknown(Some(&json!({ "process_status": { "lifecycle": 123 } }))));
1372    }
1373
1374    #[test]
1375    fn classify_last_error_source_matches_every_known_construction_site() {
1376        // The exact message text each of the four call sites builds today
1377        // (identity/resolver/errors.rs, container_spawn.rs, host_spawn.rs,
1378        // agent_handlers/input.rs) — with the `[AgentMux] ` prefix already
1379        // stripped, as the real caller does before classifying.
1380        assert_eq!(
1381            classify_last_error_source("no credentials for claude: the bound account was deleted or is unresolvable. Bind an account for this provider in Connectors → Accounts."),
1382            "identity"
1383        );
1384        assert_eq!(
1385            classify_last_error_source("credential injection could not run (task join failed); the spawn was refused rather than falling back to the global CLI login. Retry, and check `muxlog auth` if it persists."),
1386            "identity"
1387        );
1388        // codex P2, PR #2802: SpawnGateError::AmbientHomeDirNotAllowed.
1389        assert_eq!(
1390            classify_last_error_source("this agent's claude identity points directly at your personal claude config directory (C:\\Users\\user\\.claude) instead of an isolated AgentMux account — AgentMux no longer allows spawning an agent against your own global CLI login. Re-bind this identity to an isolated account in Connectors \u{2192} Accounts (delete the current claude account and log in again to create a fresh, isolated one), then retry."),
1391            "identity"
1392        );
1393        // ReAgent P2, PR #2854: SpawnGateError::ClaudeMdSeedFailed.
1394        assert_eq!(
1395            classify_last_error_source("could not isolate this agent's claude config directory (C:\\Users\\user\\.agentmux\\shared\\identities\\id-1\\claude): permission denied. Refusing to spawn with an unprotected config dir — retry, and check the directory's permissions if it persists."),
1396            "identity"
1397        );
1398        assert_eq!(
1399            classify_last_error_source("container exec failed: no such container"),
1400            "container_spawn"
1401        );
1402        assert_eq!(
1403            classify_last_error_source("container ensure_running failed: image not found"),
1404            "container_spawn"
1405        );
1406        assert_eq!(
1407            classify_last_error_source("Couldn't start the container for this agent: the registry refused access to x."),
1408            "container_spawn"
1409        );
1410        assert_eq!(
1411            classify_last_error_source("queued message could not be sent: lease held by another process"),
1412            "host_spawn"
1413        );
1414        assert_eq!(classify_last_error_source("something nobody wrote yet"), "unknown");
1415        // #3577: SpawnGateError::AgentDeleted — its Display starts
1416        // "this agent's", so it files under identity with the other gate
1417        // refusals.
1418        assert_eq!(
1419            classify_last_error_source(
1420                &crate::identity::resolver::SpawnGateError::AgentDeleted {
1421                    agent_id: "uid-x".to_string()
1422                }
1423                .to_string()
1424            ),
1425            "identity"
1426        );
1427    }
1428
1429    #[test]
1430    fn last_error_frame_none_for_missing_block() {
1431        let filestore = FileStore::open_in_memory().unwrap();
1432        assert!(last_error_frame(&filestore, "no-such-block").is_none());
1433    }
1434
1435    #[test]
1436    fn last_error_frame_none_for_empty_output() {
1437        let filestore = FileStore::open_in_memory().unwrap();
1438        filestore
1439            .make_file("block-1", "output", FileMeta::new(), FileOpts::default())
1440            .unwrap();
1441        assert!(last_error_frame(&filestore, "block-1").is_none());
1442    }
1443
1444    #[test]
1445    fn last_error_frame_found_when_last_line_is_the_frame() {
1446        let filestore = FileStore::open_in_memory().unwrap();
1447        filestore
1448            .make_file("block-1", "output", FileMeta::new(), FileOpts::default())
1449            .unwrap();
1450        let mut content = String::new();
1451        content.push_str("{\"type\":\"assistant\",\"text\":\"normal turn output\"}\n");
1452        content.push_str(&error_frame_line(
1453            "[AgentMux] no credentials for claude: bind an account in Connectors → Accounts.",
1454        ));
1455        content.push('\n');
1456        filestore.write_file("block-1", "output", content.as_bytes()).unwrap();
1457
1458        let found = last_error_frame(&filestore, "block-1").expect("last line is an error frame");
1459        assert_eq!(
1460            found.message,
1461            "[AgentMux] no credentials for claude: bind an account in Connectors → Accounts."
1462        );
1463        assert_eq!(found.source, "identity");
1464        assert!(found.written_ms > 0);
1465    }
1466
1467    /// Pins the "only the LAST non-blank line counts" rule (see the
1468    /// function's own doc comment): a block that errored once and then
1469    /// recovered — kept producing normal output afterward — must NOT be
1470    /// flagged. Only "the last thing that happened was an unrecovered
1471    /// error" is what this exists to surface.
1472    #[test]
1473    fn last_error_frame_none_when_normal_output_follows_an_earlier_error() {
1474        let filestore = FileStore::open_in_memory().unwrap();
1475        filestore
1476            .make_file("block-1", "output", FileMeta::new(), FileOpts::default())
1477            .unwrap();
1478        let mut content = String::new();
1479        content.push_str(&error_frame_line("[AgentMux] container exec failed: transient"));
1480        content.push('\n');
1481        content.push_str("{\"type\":\"assistant\",\"text\":\"turn resumed fine after the retry\"}\n");
1482        filestore.write_file("block-1", "output", content.as_bytes()).unwrap();
1483
1484        assert!(last_error_frame(&filestore, "block-1").is_none());
1485    }
1486
1487    #[test]
1488    fn last_error_frame_ignores_a_result_frame_that_is_not_an_error() {
1489        let filestore = FileStore::open_in_memory().unwrap();
1490        filestore
1491            .make_file("block-1", "output", FileMeta::new(), FileOpts::default())
1492            .unwrap();
1493        let normal_result = json!({"type": "result", "is_error": false, "subtype": "success"}).to_string();
1494        filestore
1495            .write_file("block-1", "output", format!("{normal_result}\n").as_bytes())
1496            .unwrap();
1497
1498        assert!(last_error_frame(&filestore, "block-1").is_none());
1499    }
1500
1501    fn dock_node(id: &str, status: &str, observed_at: i64) -> crate::backend::dock_snapshot::DockNodeSnapshot {
1502        crate::backend::dock_snapshot::DockNodeSnapshot {
1503            node_id: id.to_string(),
1504            tool_name: "Bash".to_string(),
1505            status: status.to_string(),
1506            timestamp: Some(observed_at),
1507            observed_at,
1508            run_in_background: None,
1509        }
1510    }
1511
1512    #[test]
1513    fn dock_node_views_flags_stuck_running_node_past_threshold_when_unbacked() {
1514        let now_ms = 100_000;
1515        let views = dock_node_views(vec![dock_node("n1", "running", 50_000)], now_ms, false);
1516        assert_eq!(views.len(), 1);
1517        assert_eq!(views[0].age_ms, 50_000);
1518        assert!(views[0].stuck, "running, 50s old, unbacked — must be flagged stuck");
1519    }
1520
1521    #[test]
1522    fn dock_node_views_does_not_flag_running_node_within_threshold() {
1523        let now_ms = 100_000;
1524        // Only 10s old — well under DOCK_STUCK_THRESHOLD_MS (30s), even unbacked.
1525        let views = dock_node_views(vec![dock_node("n1", "running", 90_000)], now_ms, false);
1526        assert!(!views[0].stuck, "a tool merely taking a while must not be flagged instantly");
1527    }
1528
1529    #[test]
1530    fn dock_node_views_does_not_flag_running_node_when_backed_by_a_live_process() {
1531        let now_ms = 100_000;
1532        // Old enough to qualify on age alone, but `backed=true` (block lifecycle
1533        // is Running) — something IS actively going on for this block.
1534        let views = dock_node_views(vec![dock_node("n1", "running", 50_000)], now_ms, true);
1535        assert!(!views[0].stuck, "backed by a live process — not stuck, just slow");
1536    }
1537
1538    #[test]
1539    fn dock_node_views_does_not_flag_non_running_nodes_regardless_of_age() {
1540        let now_ms = 100_000;
1541        for status in ["success", "failed", "canceled", "denied"] {
1542            let views = dock_node_views(vec![dock_node("n1", status, 0)], now_ms, false);
1543            assert!(!views[0].stuck, "status={status} already resolved, must never be flagged stuck");
1544        }
1545    }
1546
1547    #[test]
1548    fn dock_node_views_preserves_every_input_node() {
1549        let now_ms = 100_000;
1550        let views = dock_node_views(
1551            vec![dock_node("n1", "running", 99_000), dock_node("n2", "success", 80_000)],
1552            now_ms,
1553            false,
1554        );
1555        assert_eq!(views.len(), 2);
1556        assert_eq!(views[0].node_id, "n1");
1557        assert_eq!(views[1].node_id, "n2");
1558    }
1559
1560    #[test]
1561    fn dock_node_views_surfaces_run_in_background_even_though_status_is_terminal() {
1562        // Issue #2518: an accepted background launch is terminal
1563        // ("success") within ~a second server-side, so `stuck` can never
1564        // catch it — this is the one signal that lets a human tell "this
1565        // success might actually still be running in the real dock" apart
1566        // from an ordinary finished call, without the server having to
1567        // duplicate the client's <task-notification> cross-referencing.
1568        let now_ms = 100_000;
1569        let mut bg_node = dock_node("n1", "success", 0);
1570        bg_node.run_in_background = Some(true);
1571        let views = dock_node_views(vec![bg_node], now_ms, false);
1572        assert_eq!(views[0].run_in_background, Some(true));
1573        assert!(!views[0].stuck, "status is terminal — the raw heuristic correctly stays quiet");
1574    }
1575
1576    fn bg_task(
1577        id: &str,
1578        status: crate::backend::storage::background_tasks::BackgroundTaskStatus,
1579        started_at_ms: i64,
1580    ) -> crate::backend::storage::background_tasks::BackgroundTask {
1581        crate::backend::storage::background_tasks::BackgroundTask {
1582            id: id.to_string(),
1583            block_id: "block-1".to_string(),
1584            label: "task dev".to_string(),
1585            pid: Some(4242),
1586            started_at_ms,
1587            status,
1588            last_seen_ms: started_at_ms,
1589            ended_at_ms: None,
1590            owner_tool_use_id: None,
1591        }
1592    }
1593
1594    #[test]
1595    fn merge_background_tasks_adds_a_running_task_the_cache_evicted() {
1596        // The exact §3.4 scenario: DockSnapshotCache's 1-hour TTL evicted
1597        // the entry, but db_background_tasks still knows it's running.
1598        use crate::backend::storage::background_tasks::BackgroundTaskStatus;
1599        let now_ms = 100_000_000;
1600        let merged = merge_background_tasks(vec![], vec![bg_task("bg-1", BackgroundTaskStatus::Running, 1_000)], now_ms);
1601        assert_eq!(merged.len(), 1);
1602        assert_eq!(merged[0].node_id, "bg-1");
1603        assert_eq!(merged[0].status, "running");
1604        assert_eq!(merged[0].age_ms, now_ms - 1_000);
1605        assert_eq!(merged[0].run_in_background, Some(true));
1606        assert!(!merged[0].stuck);
1607    }
1608
1609    #[test]
1610    fn merge_background_tasks_skips_a_task_the_cache_still_has() {
1611        use crate::backend::storage::background_tasks::BackgroundTaskStatus;
1612        let now_ms = 100_000;
1613        let existing = dock_node_views(vec![dock_node("bg-1", "running", 90_000)], now_ms, false);
1614        let merged = merge_background_tasks(existing, vec![bg_task("bg-1", BackgroundTaskStatus::Running, 1_000)], now_ms);
1615        assert_eq!(merged.len(), 1, "must not duplicate a node the live cache already has");
1616        assert_eq!(merged[0].tool_name, "Bash", "the live cache's own entry wins, not a synthesized one");
1617    }
1618
1619    #[test]
1620    fn merge_background_tasks_ignores_non_running_tasks() {
1621        use crate::backend::storage::background_tasks::BackgroundTaskStatus;
1622        let now_ms = 100_000;
1623        for status in [BackgroundTaskStatus::Done, BackgroundTaskStatus::Error, BackgroundTaskStatus::Stopped] {
1624            let merged = merge_background_tasks(vec![], vec![bg_task("bg-1", status, 1_000)], now_ms);
1625            assert!(merged.is_empty(), "status={status:?} is terminal — must not be synthesized as a live dock row");
1626        }
1627    }
1628
1629    #[test]
1630    fn merge_background_tasks_on_no_tasks_is_a_true_no_op() {
1631        let now_ms = 100_000;
1632        let existing = dock_node_views(vec![dock_node("n1", "running", 90_000)], now_ms, false);
1633        let expected_len = existing.len();
1634        let merged = merge_background_tasks(existing, vec![], now_ms);
1635        assert_eq!(merged.len(), expected_len);
1636        assert_eq!(merged[0].node_id, "n1");
1637    }
1638
1639    #[tokio::test]
1640    async fn handle_muxspect_dock_rejects_empty_block_id() {
1641        let state = crate::server::tests::test_state();
1642        let resp = handle_muxspect_dock(
1643            State(state),
1644            Query(MuxspectDockQuery { block_id: String::new() }),
1645        )
1646        .await
1647        .into_response();
1648        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1649    }
1650
1651    #[tokio::test]
1652    async fn handle_muxspect_dock_clear_rejects_empty_ids() {
1653        let state = crate::server::tests::test_state();
1654        let resp = handle_muxspect_dock_clear(
1655            State(state),
1656            Json(MuxspectDockClearRequest { block_id: String::new(), node_id: "n1".to_string() }),
1657        )
1658        .await
1659        .into_response();
1660        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1661    }
1662
1663    #[tokio::test]
1664    async fn handle_muxspect_dock_clear_404s_on_unknown_node() {
1665        let state = crate::server::tests::test_state();
1666        let resp = handle_muxspect_dock_clear(
1667            State(state),
1668            Json(MuxspectDockClearRequest { block_id: "block-1".to_string(), node_id: "no-such-node".to_string() }),
1669        )
1670        .await
1671        .into_response();
1672        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1673    }
1674
1675    #[tokio::test]
1676    async fn handle_muxspect_dock_clear_succeeds_and_removes_from_cache() {
1677        let state = crate::server::tests::test_state();
1678        state.dock_snapshots.push_delta("block-1", dock_node("n1", "running", 0));
1679        assert!(state.dock_snapshots.has_node("block-1", "n1"));
1680
1681        let resp = handle_muxspect_dock_clear(
1682            State(state.clone()),
1683            Json(MuxspectDockClearRequest { block_id: "block-1".to_string(), node_id: "n1".to_string() }),
1684        )
1685        .await
1686        .into_response();
1687        assert_eq!(resp.status(), StatusCode::OK);
1688        assert!(
1689            !state.dock_snapshots.has_node("block-1", "n1"),
1690            "a cleared node must not still be reported by a subsequent `dock` read"
1691        );
1692    }
1693
1694    async fn json_body(resp: axum::response::Response) -> serde_json::Value {
1695        let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
1696        serde_json::from_slice(&body).unwrap()
1697    }
1698
1699    #[tokio::test]
1700    async fn handle_muxspect_background_tasks_scoped_to_block_includes_terminal_tasks() {
1701        let state = crate::server::tests::test_state();
1702        state.mstore.background_task_observe("t1", "block-1", "task dev", 0, 0).unwrap();
1703        state.mstore.background_task_observe("t2", "block-1", "finished build", 0, 0).unwrap();
1704        state
1705            .mstore
1706            .background_task_complete(
1707                "t2",
1708                crate::backend::storage::background_tasks::BackgroundTaskStatus::Done,
1709                500,
1710            )
1711            .unwrap();
1712        // Different block — must not leak into a block-scoped query.
1713        state.mstore.background_task_observe("t3", "block-2", "other pane", 0, 0).unwrap();
1714
1715        let resp = handle_muxspect_background_tasks(
1716            State(state),
1717            Query(MuxspectBackgroundTasksQuery { block_id: Some("block-1".to_string()) }),
1718        )
1719        .await
1720        .into_response();
1721        assert_eq!(resp.status(), StatusCode::OK);
1722        let body = json_body(resp).await;
1723        let ids: Vec<&str> = body["tasks"].as_array().unwrap().iter().map(|t| t["id"].as_str().unwrap()).collect();
1724        assert_eq!(ids, vec!["t1", "t2"], "block-scoped query includes terminal tasks for that block");
1725    }
1726
1727    #[tokio::test]
1728    async fn handle_muxspect_background_tasks_without_block_id_lists_running_globally() {
1729        let state = crate::server::tests::test_state();
1730        state.mstore.background_task_observe("t1", "block-1", "still running", 0, 0).unwrap();
1731        state.mstore.background_task_observe("t2", "block-2", "also running", 0, 0).unwrap();
1732        state.mstore.background_task_observe("t3", "block-1", "finished", 0, 0).unwrap();
1733        state
1734            .mstore
1735            .background_task_complete(
1736                "t3",
1737                crate::backend::storage::background_tasks::BackgroundTaskStatus::Done,
1738                500,
1739            )
1740            .unwrap();
1741
1742        let resp = handle_muxspect_background_tasks(
1743            State(state),
1744            Query(MuxspectBackgroundTasksQuery { block_id: None }),
1745        )
1746        .await
1747        .into_response();
1748        assert_eq!(resp.status(), StatusCode::OK);
1749        let body = json_body(resp).await;
1750        let ids: Vec<&str> = body["tasks"].as_array().unwrap().iter().map(|t| t["id"].as_str().unwrap()).collect();
1751        assert_eq!(ids, vec!["t1", "t2"], "global query is running-only, across every block");
1752    }
1753
1754    fn candidate(name: &str, tier: &'static str, last_seen_ms: Option<i64>, stale_eligible: bool) -> SenderCandidate {
1755        SenderCandidate { name: name.to_string(), tier, last_seen_ms, stale_eligible, channel: None, local_url: None }
1756    }
1757
1758    #[test]
1759    fn classify_sender_not_found_on_empty_candidates() {
1760        let verdict = classify_sender("AgentA", &[], 1_000_000);
1761        assert_eq!(verdict.status, "not_found");
1762        assert_eq!(verdict.tier, None);
1763    }
1764
1765    #[test]
1766    fn classify_sender_found_on_host_tier() {
1767        let candidates = [candidate("AgentA", "host", Some(999_000), false)];
1768        let verdict = classify_sender("AgentA", &candidates, 1_000_000);
1769        assert_eq!(verdict.status, "found");
1770        assert_eq!(verdict.tier, Some("host"));
1771    }
1772
1773    #[test]
1774    fn classify_sender_found_on_cross_channel_tier() {
1775        let candidates = [candidate("AgentA", "cross-channel", Some(999_000), true)];
1776        let verdict = classify_sender("AgentA", &candidates, 1_000_000);
1777        assert_eq!(verdict.status, "found");
1778        assert_eq!(verdict.tier, Some("cross-channel"));
1779    }
1780
1781    #[test]
1782    fn classify_sender_found_on_lan_or_wan_tier() {
1783        let lan = classify_sender("Peer", &[candidate("Peer", "lan", Some(999_000), true)], 1_000_000);
1784        assert_eq!(lan.status, "found");
1785        let wan = classify_sender("Peer", &[candidate("Peer", "wan", None, false)], 1_000_000);
1786        assert_eq!(wan.status, "found");
1787    }
1788
1789    #[test]
1790    fn classify_sender_name_match_is_case_insensitive() {
1791        let candidates = [candidate("agenta", "host", Some(999_000), false)];
1792        let verdict = classify_sender("AgentA", &candidates, 1_000_000);
1793        assert_eq!(verdict.status, "found");
1794    }
1795
1796    /// Host tier must NEVER be flagged stale, no matter how old
1797    /// `last_seen_ms` is — `ReactiveHandler::list_agents()` is
1798    /// synchronously accurate (removed on unregister, not aged out), and
1799    /// nothing in this codebase refreshes `AgentRegistration.last_seen`
1800    /// after registration. Applying a staleness cutoff here would flag
1801    /// every long-running, perfectly healthy host-tier agent as stale
1802    /// (codex review on PR #2702, P1 — this pins the fix).
1803    #[test]
1804    fn classify_sender_host_tier_never_stale_regardless_of_age() {
1805        let now_ms = 1_000_000;
1806        let ancient = now_ms - 10 * crate::backend::reactive::registry::FORWARD_FAILURE_GRACE_MS as i64;
1807        let candidates = [candidate("AgentA", "host", Some(ancient), false)];
1808        let verdict = classify_sender("AgentA", &candidates, now_ms);
1809        assert_eq!(verdict.status, "found", "host tier is stale_eligible: false — age must never matter");
1810    }
1811
1812    /// Cross-channel and LAN entries DO get a real, periodically-refreshed
1813    /// timestamp (the 20s heartbeat task for cross-channel, mDNS
1814    /// re-announce for LAN) — pins that a hit past the real
1815    /// `FORWARD_FAILURE_GRACE_MS` threshold (reused from `registry.rs`,
1816    /// not a separately-invented constant) downgrades to `stale` rather
1817    /// than being silently dropped to `not_found`.
1818    #[test]
1819    fn classify_sender_stale_when_last_seen_past_the_real_grace_threshold() {
1820        let now_ms = 1_000_000;
1821        let stale_ts = now_ms - crate::backend::reactive::registry::FORWARD_FAILURE_GRACE_MS as i64;
1822        let candidates = [candidate("AgentA", "cross-channel", Some(stale_ts), true)];
1823        let verdict = classify_sender("AgentA", &candidates, now_ms);
1824        assert_eq!(verdict.status, "stale");
1825        assert_eq!(verdict.tier, Some("cross-channel"), "still reports which tier it was found on");
1826    }
1827
1828    #[test]
1829    fn classify_sender_wan_tier_has_no_last_seen_and_is_never_stale() {
1830        // WAN candidates carry no last_seen_ms (cloud_subscriber doesn't
1831        // track a per-agent heartbeat) — must not be misclassified as
1832        // stale just because the field is absent.
1833        let candidates = [candidate("AgentA", "wan", None, false)];
1834        let verdict = classify_sender("AgentA", &candidates, 1_000_000);
1835        assert_eq!(verdict.status, "found");
1836    }
1837
1838    /// Priority (non-stale case): a name present on multiple tiers reports
1839    /// the FIRST match — callers push candidates in tier-priority order
1840    /// (host, cross-channel, lan, wan) — so a host hit wins over a later
1841    /// cross-channel entry for the same name.
1842    #[test]
1843    fn classify_sender_reports_first_matching_tier_when_present_on_multiple() {
1844        let candidates = [
1845            candidate("AgentA", "host", Some(999_000), false),
1846            candidate("AgentA", "cross-channel", Some(999_000), true),
1847        ];
1848        let verdict = classify_sender("AgentA", &candidates, 1_000_000);
1849        assert_eq!(verdict.tier, Some("host"));
1850    }
1851
1852    /// Priority (staleness case): a STALE earlier-tier match must not win
1853    /// over a LIVE later-tier match for the same name — picking the
1854    /// literal first match regardless of staleness could report `stale`
1855    /// even though a live entry for the same sender existed elsewhere in
1856    /// the candidate list (codex review on PR #2702, P2 — this pins the
1857    /// fix). Only when EVERY match is stale does the first (most-trusted)
1858    /// one win as a fallback.
1859    #[test]
1860    fn classify_sender_prefers_a_live_candidate_over_an_earlier_stale_one() {
1861        let now_ms = 1_000_000;
1862        let stale_ts = now_ms - crate::backend::reactive::registry::FORWARD_FAILURE_GRACE_MS as i64;
1863        let candidates = [
1864            candidate("AgentA", "cross-channel", Some(stale_ts), true), // stale, but tier-priority first
1865            candidate("AgentA", "lan", Some(999_000), true),            // live, lower tier-priority
1866        ];
1867        let verdict = classify_sender("AgentA", &candidates, now_ms);
1868        assert_eq!(verdict.status, "found");
1869        assert_eq!(verdict.tier, Some("lan"));
1870    }
1871
1872    #[test]
1873    fn classify_sender_falls_back_to_first_stale_match_when_all_are_stale() {
1874        let now_ms = 1_000_000;
1875        let stale_ts = now_ms - crate::backend::reactive::registry::FORWARD_FAILURE_GRACE_MS as i64;
1876        let candidates = [
1877            candidate("AgentA", "cross-channel", Some(stale_ts), true),
1878            candidate("AgentA", "lan", Some(stale_ts), true),
1879        ];
1880        let verdict = classify_sender("AgentA", &candidates, now_ms);
1881        assert_eq!(verdict.status, "stale");
1882        assert_eq!(verdict.tier, Some("cross-channel"), "falls back to the most-trusted tier among stale matches");
1883    }
1884
1885    #[tokio::test]
1886    async fn handle_muxspect_verify_sender_rejects_empty_name() {
1887        let state = crate::server::tests::test_state();
1888        let resp = handle_muxspect_verify_sender(State(state), Query(MuxspectVerifySenderQuery { name: String::new() }))
1889            .await
1890            .into_response();
1891        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1892    }
1893
1894    #[tokio::test]
1895    async fn handle_muxspect_verify_sender_not_found_is_still_200() {
1896        // A "no such sender" verdict is a legitimate query result, not a
1897        // caller error — must stay 200 so muxspect.mjs's apiGet() (which
1898        // treats any non-2xx as fatal) doesn't turn it into a hard failure.
1899        let state = crate::server::tests::test_state();
1900        let resp = handle_muxspect_verify_sender(
1901            State(state),
1902            Query(MuxspectVerifySenderQuery { name: "NoSuchAgent".to_string() }),
1903        )
1904        .await
1905        .into_response();
1906        assert_eq!(resp.status(), StatusCode::OK);
1907        let body = json_body(resp).await;
1908        assert_eq!(body["status"], "not_found");
1909    }
1910
1911    #[tokio::test]
1912    async fn handle_muxspect_conversations_empty_state_is_still_200() {
1913        // No agents registered at all is a legitimate result, not an error —
1914        // same "encode absence in the body, not the status" posture as the
1915        // rest of this file's handlers.
1916        let state = crate::server::tests::test_state();
1917        let resp = handle_muxspect_conversations(State(state)).await.into_response();
1918        assert_eq!(resp.status(), StatusCode::OK);
1919        let body = json_body(resp).await;
1920        assert!(body["agents"].is_array());
1921    }
1922
1923    #[tokio::test]
1924    async fn handle_muxspect_conversations_includes_a_registered_host_agent() {
1925        let state = crate::server::tests::test_state();
1926        let unique = uuid::Uuid::new_v4();
1927        let agent_id = format!("conversations-test-agent-{unique}");
1928        let block_id = format!("conversations-test-block-{unique}");
1929        state
1930            .reactive_handler
1931            .register_agent(&agent_id, &block_id, None)
1932            .unwrap();
1933
1934        let resp = handle_muxspect_conversations(State(state)).await.into_response();
1935        assert_eq!(resp.status(), StatusCode::OK);
1936        let body = json_body(resp).await;
1937        let agents = body["agents"].as_array().expect("agents array");
1938        let entry = agents
1939            .iter()
1940            .find(|a| a["name"] == agent_id)
1941            .expect("registered agent should appear in the listing");
1942        assert_eq!(entry["tier"], "host");
1943        // No filestore content written for this block in this test — a
1944        // missing preview is the correct, non-error result (see
1945        // last_line_preview's own doc comment), not something this test
1946        // should treat as a failure.
1947        assert_eq!(entry["last_message_preview"], serde_json::Value::Null);
1948    }
1949
1950    /// Minimal, non-archived `Block` row so `read_session_output` takes its
1951    /// FileStore-read path — matches the pattern
1952    /// `session_archive.rs`'s own tests use, just without the extra
1953    /// archival-specific meta this module's read path never inspects.
1954    fn insert_bare_block(
1955        mstore: &std::sync::Arc<crate::backend::storage::store::Store>,
1956        block_id: &str,
1957    ) {
1958        use crate::backend::obj::Block;
1959        let mut block = Block {
1960            oid: block_id.to_string(),
1961            parentoref: String::new(),
1962            version: 1,
1963            runtimeopts: None,
1964            stickers: None,
1965            meta: Default::default(),
1966            subblockids: None,
1967        };
1968        mstore.insert(&mut block).expect("mstore insert");
1969    }
1970
1971    #[test]
1972    fn last_line_preview_and_activity_returns_last_non_blank_line() {
1973        let filestore = std::sync::Arc::new(FileStore::open_in_memory().unwrap());
1974        let mstore = std::sync::Arc::new(crate::backend::storage::store::Store::open_in_memory().unwrap());
1975        let block_id = uuid::Uuid::new_v4().to_string();
1976        filestore
1977            .make_file(&block_id, "output", crate::backend::storage::filestore::FileMeta::default(), crate::backend::storage::filestore::FileOpts::default())
1978            .expect("make_file");
1979        filestore
1980            .append_data(&block_id, "output", b"first line\n\nlast line\n")
1981            .expect("append_data");
1982        insert_bare_block(&mstore, &block_id);
1983
1984        let (preview, activity_ms) = last_line_preview_and_activity(&mstore, &filestore, &block_id);
1985        assert_eq!(preview, Some("last line".to_string()));
1986        // Fast path (live FileStore file present) — a real write timestamp
1987        // must come back, not None, per codex P2 on PR #2715.
1988        assert!(activity_ms.is_some());
1989    }
1990
1991    #[test]
1992    fn last_line_preview_and_activity_truncates_long_lines() {
1993        let filestore = std::sync::Arc::new(FileStore::open_in_memory().unwrap());
1994        let mstore = std::sync::Arc::new(crate::backend::storage::store::Store::open_in_memory().unwrap());
1995        let block_id = uuid::Uuid::new_v4().to_string();
1996        let long_line = "x".repeat(PREVIEW_MAX_CHARS + 50);
1997        filestore
1998            .make_file(&block_id, "output", crate::backend::storage::filestore::FileMeta::default(), crate::backend::storage::filestore::FileOpts::default())
1999            .expect("make_file");
2000        filestore
2001            .append_data(&block_id, "output", format!("{long_line}\n").as_bytes())
2002            .expect("append_data");
2003        insert_bare_block(&mstore, &block_id);
2004
2005        let (preview, _) = last_line_preview_and_activity(&mstore, &filestore, &block_id);
2006        let preview = preview.unwrap();
2007        assert_eq!(preview.chars().count(), PREVIEW_MAX_CHARS + 1); // +1 for the trailing "…"
2008        assert!(preview.ends_with('…'));
2009    }
2010
2011    #[test]
2012    fn last_line_preview_and_activity_returns_none_for_missing_block() {
2013        let filestore = std::sync::Arc::new(FileStore::open_in_memory().unwrap());
2014        let mstore = std::sync::Arc::new(crate::backend::storage::store::Store::open_in_memory().unwrap());
2015        let (preview, activity_ms) = last_line_preview_and_activity(&mstore, &filestore, "no-such-block");
2016        assert_eq!(preview, None);
2017        assert_eq!(activity_ms, None);
2018    }
2019
2020    /// `flatten_layout_nodes` is what `muxspect layout` renders from, so its
2021    /// shape is the contract. Covers the cross-split arrangement the layout
2022    /// fixes (#2848/#2850/#2855) came from: a Row nested inside a Column.
2023    #[test]
2024    fn flatten_layout_nodes_reports_depth_path_and_effective_minimize() {
2025        use agentmux_common::{FlexDirection, LayoutNode, LayoutNodeData};
2026
2027        fn leaf(id: &str, block: &str, minimized: bool) -> LayoutNode {
2028            let mut n = LayoutNode {
2029                id: id.to_string(),
2030                flex_direction: FlexDirection::Row,
2031                size: 10.0,
2032                children: Vec::new(),
2033                data: Some(LayoutNodeData {
2034                    block_id: block.to_string(),
2035                    ..Default::default()
2036                }),
2037                ..Default::default()
2038            };
2039            if minimized {
2040                // Round-trips through `extra`, not a typed field — same as
2041                // the frontend writes it.
2042                n.extra.insert("minimized".to_string(), json!(true));
2043            }
2044            n
2045        }
2046
2047        // Column[ top, Row[ A(min), B(min) ], bottom ]
2048        let inner = LayoutNode {
2049            id: "inner".into(),
2050            flex_direction: FlexDirection::Row,
2051            size: 10.0,
2052            children: vec![leaf("a", "blk-a", true), leaf("b", "blk-b", true)],
2053            data: None,
2054            ..Default::default()
2055        };
2056        let root = LayoutNode {
2057            id: "root".into(),
2058            flex_direction: FlexDirection::Column,
2059            size: 10.0,
2060            children: vec![leaf("top", "blk-top", false), inner, leaf("bot", "blk-bot", false)],
2061            data: None,
2062            ..Default::default()
2063        };
2064
2065        let mut out = Vec::new();
2066        flatten_layout_nodes(&root, 0, "", &mut out);
2067
2068        // Flat, pre-order, one entry per node.
2069        assert_eq!(out.len(), 6);
2070        assert_eq!(out[0]["id"], "root");
2071        assert_eq!(out[0]["depth"], 0);
2072        assert_eq!(out[0]["kind"], "branch");
2073        assert_eq!(out[0]["flex_direction"], "column");
2074
2075        // Index path is positional, so a reader can locate a node in the tree.
2076        assert_eq!(out[1]["path"], "0");
2077        assert_eq!(out[2]["path"], "1");
2078        assert_eq!(out[3]["path"], "1.0");
2079        assert_eq!(out[3]["depth"], 2);
2080
2081        // The inner Row is a BRANCH with no minimized flag of its own, but is
2082        // effectively minimized because every leaf under it is — the state
2083        // that drives chip geometry, and invisible from `locked` alone. This
2084        // is the distinction the cross-split bugs turned on.
2085        assert_eq!(out[2]["id"], "inner");
2086        assert_eq!(out[2]["locked"], false);
2087        assert_eq!(out[2]["effectively_minimized"], true);
2088        assert_eq!(out[2]["child_count"], 2);
2089
2090        // Leaves carry their block id; the minimized ones report locked.
2091        assert_eq!(out[3]["block_id"], "blk-a");
2092        assert_eq!(out[3]["locked"], true);
2093        assert_eq!(out[1]["locked"], false);
2094        assert_eq!(out[1]["effectively_minimized"], false);
2095    }
2096
2097    #[test]
2098    fn truncate_preview_is_shared_by_host_and_cross_channel_paths() {
2099        // Pins the fix for codex P2 on PR #2715 (cross-channel previews
2100        // originally skipped truncation entirely) at the unit-test level
2101        // — the handler-level concurrent-fetch behavior itself needs a
2102        // live second instance to exercise end-to-end, out of reach for
2103        // this module's test harness.
2104        let long = "y".repeat(PREVIEW_MAX_CHARS + 10);
2105        let truncated = truncate_preview(&long);
2106        assert_eq!(truncated.chars().count(), PREVIEW_MAX_CHARS + 1);
2107        assert!(truncated.ends_with('…'));
2108
2109        let short = "short line";
2110        assert_eq!(truncate_preview(short), short);
2111    }
2112}