heddle-cli 0.2.4

An AI-native version control system
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
// SPDX-License-Identifier: Apache-2.0
//! Mount-daemon client: discovers, spawns, and RPCs the daemon.
//!
//! Mirrors the fsmonitor's `try_local_helper_query` pattern:
//!
//! * Read the endpoint file.
//! * If it's missing or stale (PID dead via `kill -0`, or
//!   version-skew), sweep any leftover mounts and respawn
//!   `heddle daemon serve` detached.
//! * Send the request, decode the response.
//!
//! Pure-Rust, no new deps. Spawning uses `std::process::Command`
//! plus `setsid` on Linux (mirrors the fsmonitor pattern; see
//! [`spawn_daemon_detached`]).

use std::{
    fs,
    path::{Path, PathBuf},
    process::{Command, Stdio},
    time::Duration,
};

use anyhow::{Context, Result, anyhow};
use repo::daemon::{
    ERR_MOUNT_UNSUPPORTED, EndpointState, MOUNT_PROTOCOL_VERSION, MountDaemonRequest,
    MountDaemonResponse, MountRegistryFile, load_endpoint, mount_daemon_endpoint_path,
    mount_daemon_registry_path, pid_alive, remove_endpoint, send_json_request,
};
use tracing::{debug, warn};

/// Outcome of a daemon mount attempt that distinguishes between
/// "the daemon couldn't service this request and we can fall back
/// to the in-process mount path" and "something went wrong that
/// the caller must surface".
///
/// The split exists because, post-default-flip, every
/// `--workspace light` start tries the daemon first and
/// silently falls back when the daemon is unavailable on this
/// host (e.g. no `fusermount`, exec failed, daemon endpoint never
/// appeared). A `Fatal` error means the daemon *did* respond but
/// signalled a real problem (mount conflict, malformed reply,
/// version mismatch in flight), and we must not paper over it.
#[derive(Debug)]
pub enum DaemonMountError {
    /// The daemon could not be reached or could not service the
    /// request because of a host-environment reason (no daemon
    /// endpoint, spawn failed, daemon reports
    /// `ERR_MOUNT_UNSUPPORTED`). Safe to retry in-process. The
    /// `String` is a short reason suitable for a one-line warning
    /// log — it has already been formatted from the underlying
    /// error.
    Unavailable(String),
    /// The daemon rejected the request for a reason that points at
    /// a real bug or a real conflict (mount already held under a
    /// different path, malformed response, etc.). Surface to the
    /// caller — falling back to in-process would either hide the
    /// conflict or duplicate the mount.
    Fatal(anyhow::Error),
}

impl std::fmt::Display for DaemonMountError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Unavailable(reason) => write!(f, "{reason}"),
            Self::Fatal(err) => write!(f, "{err}"),
        }
    }
}

/// How long we'll wait between checks for the endpoint file
/// appearing after we spawn the daemon. Mirrors fsmonitor.
const SPAWN_RETRY_DELAY_MS: u64 = 50;
const SPAWN_RETRIES: usize = 10;

/// Resolve a *currently live* daemon endpoint for `repo_root`. If
/// none is present we spawn one in the background and wait up to
/// ~500 ms for the endpoint file to appear. Returns `None` if the
/// caller asked us not to spawn (e.g. `daemon status`) and the
/// daemon isn't already running.
pub fn ensure_daemon_endpoint(
    repo_root: &Path,
    spawn_if_missing: bool,
) -> Result<Option<EndpointState>> {
    let endpoint_path = mount_daemon_endpoint_path(repo_root);

    if let Some(endpoint) = read_live_endpoint(&endpoint_path)? {
        return Ok(Some(endpoint));
    }
    // Endpoint absent or stale. Sweep before respawning so we don't
    // leave a wedged FUSE mount behind from the dead daemon.
    sweep_stale_mounts(repo_root);
    remove_endpoint(&endpoint_path);

    if !spawn_if_missing {
        return Ok(None);
    }

    spawn_daemon_detached(repo_root)?;
    for _ in 0..SPAWN_RETRIES {
        if let Some(endpoint) = read_live_endpoint(&endpoint_path)? {
            return Ok(Some(endpoint));
        }
        std::thread::sleep(Duration::from_millis(SPAWN_RETRY_DELAY_MS));
    }
    Err(anyhow!(
        "daemon endpoint never appeared at {}; check `heddle daemon serve` for errors",
        endpoint_path.display()
    ))
}

/// Read the endpoint file and decide whether to trust it. Returns
/// `Ok(None)` if the file is missing, the version doesn't match
/// what this CLI speaks, or the recorded PID is dead. Errors only
/// propagate when the file is unreadable for a reason other than
/// not-found.
fn read_live_endpoint(endpoint_path: &Path) -> Result<Option<EndpointState>> {
    let endpoint = match load_endpoint(endpoint_path) {
        Ok(endpoint) => endpoint,
        Err(objects::error::HeddleError::Io(error))
            if error.kind() == std::io::ErrorKind::NotFound =>
        {
            return Ok(None);
        }
        Err(error) => {
            warn!(%error, path = %endpoint_path.display(), "ignoring unreadable daemon endpoint");
            return Ok(None);
        }
    };
    if endpoint.version != MOUNT_PROTOCOL_VERSION {
        warn!(
            recorded = endpoint.version,
            expected = MOUNT_PROTOCOL_VERSION,
            "daemon version mismatch on endpoint file; treating as stale"
        );
        return Ok(None);
    }
    if let Some(pid) = endpoint.pid
        && !pid_alive(pid)
    {
        warn!(pid, "daemon PID is dead; treating endpoint as stale");
        return Ok(None);
    }
    Ok(Some(endpoint))
}

/// Best-effort cleanup pass: read `mounts.json`, run
/// `fusermount -u` against each registered mount path, then drop
/// the file. Errors are logged but never propagated — the caller
/// can still run; a wedged mount surfaces on the next CLI use as a
/// "wedged mount: run heddle thread drop --force ..." hint.
pub fn sweep_stale_mounts(repo_root: &Path) {
    let registry_path = mount_daemon_registry_path(repo_root);
    let Ok(contents) = fs::read_to_string(&registry_path) else {
        return;
    };
    let registry: MountRegistryFile = match serde_json::from_str(&contents) {
        Ok(registry) => registry,
        Err(error) => {
            warn!(%error, path = %registry_path.display(), "stale mount registry was unparseable; removing");
            let _ = fs::remove_file(&registry_path);
            return;
        }
    };
    for entry in &registry.mounts {
        debug!(thread = %entry.thread_id, path = %entry.mount_path.display(), "sweeping stale mount");
        attempt_fusermount_unmount(&entry.mount_path);
    }
    let _ = fs::remove_file(&registry_path);
}

#[cfg(target_os = "linux")]
fn attempt_fusermount_unmount(mount_path: &Path) {
    if let Err(error) = Command::new("fusermount")
        .arg("-u")
        .arg(mount_path)
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
    {
        warn!(%error, path = %mount_path.display(), "fusermount -u failed during sweep");
    }
}

#[cfg(not(target_os = "linux"))]
fn attempt_fusermount_unmount(_mount_path: &Path) {
    // Daemon mode is Linux-only; on other platforms there's nothing
    // to sweep. Keeping the function defined keeps the call sites
    // platform-agnostic.
}

/// Spawn `heddle daemon serve` as a detached background process.
/// Stdin/stdout/stderr nulled so the daemon survives the parent
/// shell exiting. On Linux we additionally call `setsid` (via
/// `pre_exec`) so the process detaches from the controlling
/// terminal. On non-Linux this returns the unsupported error
/// before reaching this code.
pub fn spawn_daemon_detached(repo_root: &Path) -> Result<()> {
    let current_exe = std::env::current_exe().context("locate current heddle executable")?;
    let mut command = Command::new(current_exe);
    command
        .arg("--repo")
        .arg(repo_root)
        .arg("daemon")
        .arg("serve")
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null());
    #[cfg(target_os = "linux")]
    {
        use std::os::unix::process::CommandExt;
        // SAFETY: setsid takes no args, returns the new sid, has no
        // memory effects. Standard Unix daemonisation primitive.
        unsafe {
            command.pre_exec(|| {
                if libc::setsid() == -1 {
                    return Err(std::io::Error::last_os_error());
                }
                Ok(())
            });
        }
    }
    command
        .spawn()
        .with_context(|| format!("spawn heddle daemon for {}", repo_root.display()))?;
    Ok(())
}

/// Send a single request to the (possibly-spawned-on-demand) daemon
/// and decode its response. Convenience wrapper used by every CLI
/// verb that talks to the daemon.
pub fn rpc(
    repo_root: &Path,
    request: &MountDaemonRequest,
    spawn_if_missing: bool,
) -> Result<Option<MountDaemonResponse>> {
    let Some(endpoint) = ensure_daemon_endpoint(repo_root, spawn_if_missing)? else {
        return Ok(None);
    };
    let response: MountDaemonResponse = match send_json_request(&endpoint, request) {
        Ok(response) => response,
        Err(error) => {
            // The send/decode failed. Re-read the endpoint file before
            // surfacing the error: if a v1 daemon binary survived the
            // CLI upgrade, the connection succeeds but the response
            // shape no longer parses. The on-disk version is the
            // ground truth — if it's lower than what we speak, give
            // the operator a clear "stop the daemon" hint instead of
            // a raw `decode helper response: ...` line.
            return Err(refine_rpc_error(repo_root, &endpoint, error));
        }
    };
    if response.version() != MOUNT_PROTOCOL_VERSION {
        // We just verified the version on the endpoint file, so a
        // mismatch here means the daemon shipped a buggy response.
        return Err(anyhow!(
            "daemon responded with protocol version {}, expected {}",
            response.version(),
            MOUNT_PROTOCOL_VERSION
        ));
    }
    Ok(Some(response))
}

/// Wrap a send/decode failure with a clearer hint when the on-disk
/// endpoint advertises an older protocol version than this CLI
/// speaks. A future-version daemon (endpoint version > ours) is left
/// alone — that's a different failure mode (likely a protocol bug)
/// and the raw error is more useful there.
fn refine_rpc_error(
    repo_root: &Path,
    endpoint: &EndpointState,
    error: objects::error::HeddleError,
) -> anyhow::Error {
    let endpoint_path = mount_daemon_endpoint_path(repo_root);
    if let Ok(recorded) = load_endpoint(&endpoint_path)
        && recorded.version < MOUNT_PROTOCOL_VERSION
    {
        return anyhow!(error).context(format!(
            "heddled daemon is older (v{their_version}) than this CLI (v{our_version}); \
             run `heddle daemon stop` to force a respawn at the current version, then retry.",
            their_version = recorded.version,
            our_version = MOUNT_PROTOCOL_VERSION,
        ));
    }
    anyhow!(error).context(format!(
        "RPC to daemon at {}:{}",
        endpoint.host, endpoint.port
    ))
}

/// Convenience helper used by the per-thread mount path. Classifies
/// failure modes into "daemon unavailable, fall back" (`Unavailable`)
/// and "daemon said no for a real reason, surface it" (`Fatal`).
///
/// Used by the default-flipped `--workspace light` path where
/// we silently fall back to the in-process mount when the host can't
/// run the daemon. Splits the RPC into endpoint-discovery + send so
/// each phase can pick its own classification.
pub fn mount_via_daemon_classified(
    repo_root: &Path,
    thread_id: &str,
    mount_path: &Path,
) -> std::result::Result<PathBuf, DaemonMountError> {
    // Phase 1: discover (or spawn) the endpoint. Any failure here
    // means the host can't host the daemon — exec failed, fusermount
    // missing on a non-Linux build, endpoint never appeared. All
    // unavailable.
    let endpoint = match ensure_daemon_endpoint(repo_root, true) {
        Ok(Some(endpoint)) => endpoint,
        Ok(None) => {
            return Err(DaemonMountError::Unavailable(
                "daemon endpoint not available and spawn was disabled".to_string(),
            ));
        }
        Err(error) => {
            return Err(DaemonMountError::Unavailable(format!(
                "could not start daemon: {error:#}"
            )));
        }
    };

    // Phase 2: send the mount request. Network errors here mean the
    // daemon died between endpoint check and send — also unavailable
    // from the user's POV.
    let request = MountDaemonRequest::Mount {
        thread_id: thread_id.to_string(),
        mount_path: mount_path.to_path_buf(),
        repo_root: repo_root.to_path_buf(),
    };
    let response: MountDaemonResponse = match send_json_request(&endpoint, &request) {
        Ok(response) => response,
        Err(error) => {
            return Err(DaemonMountError::Unavailable(format!(
                "RPC to daemon at {}:{} failed: {error}",
                endpoint.host, endpoint.port
            )));
        }
    };
    if response.version() != MOUNT_PROTOCOL_VERSION {
        // Endpoint version was right but the response disagrees: a
        // real daemon-side bug. Don't paper over.
        return Err(DaemonMountError::Fatal(anyhow!(
            "daemon responded with protocol version {}, expected {}",
            response.version(),
            MOUNT_PROTOCOL_VERSION
        )));
    }

    // Phase 3: classify the daemon-level reply. Only
    // `ERR_MOUNT_UNSUPPORTED` is a "fall back, this host can't
    // actually mount" signal; everything else (mount_conflict,
    // version_mismatch, unknown codes) is a real problem the caller
    // must see.
    match response {
        MountDaemonResponse::Mount {
            ok: true,
            mount_path,
            ..
        } => Ok(mount_path),
        MountDaemonResponse::Error { code, message, .. } if code == ERR_MOUNT_UNSUPPORTED => Err(
            DaemonMountError::Unavailable(format!("daemon cannot mount on this host: {message}")),
        ),
        MountDaemonResponse::Error { code, message, .. } => Err(DaemonMountError::Fatal(anyhow!(
            "daemon mount failed: [{code}] {message}"
        ))),
        other => Err(DaemonMountError::Fatal(anyhow!(
            "daemon returned unexpected response: {other:?}"
        ))),
    }
}

/// Convenience helper used by `cmd_thread_drop` for daemon-spawned
/// mounts. Returns `was_mounted`.
pub fn unmount_via_daemon(repo_root: &Path, thread_id: &str) -> Result<bool> {
    let request = MountDaemonRequest::Unmount {
        thread_id: thread_id.to_string(),
    };
    let response = rpc(repo_root, &request, false)?;
    match response {
        Some(MountDaemonResponse::Unmount { was_mounted, .. }) => Ok(was_mounted),
        Some(MountDaemonResponse::Error { code, message, .. }) => {
            Err(anyhow!("daemon unmount failed: [{code}] {message}"))
        }
        Some(other) => Err(anyhow!("daemon returned unexpected response: {other:?}")),
        // No daemon running means there's nothing to unmount via the
        // daemon path. The in-process registry handles its own.
        None => Ok(false),
    }
}

#[cfg(test)]
mod tests {
    //! Stale-endpoint and sweep-after-crash tests. These run on every
    //! supported host because the only Linux-only piece (the actual
    //! `fusermount -u` shellout) is gated by `cfg(target_os = "linux")`
    //! and is a no-op everywhere else. The end-to-end Linux path is
    //! covered by the existing virtualized-mount integration test in
    //! `crates/cli/tests/multi_agent_worktrees/virtualized_mount.rs`.

    use std::{io::Write, net::TcpListener, path::PathBuf};

    use repo::daemon::{
        EndpointState, MOUNT_PROTOCOL_VERSION, MountDaemonRequest, MountRegistryFile,
        PersistedMount, mount_daemon_endpoint_path, mount_daemon_registry_path, persist_endpoint,
    };
    use tempfile::TempDir;

    use super::*;

    fn write_endpoint(repo_root: &Path, endpoint: &EndpointState) {
        let path = mount_daemon_endpoint_path(repo_root);
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent).unwrap();
        }
        persist_endpoint(&path, endpoint).unwrap();
    }

    /// A version-skewed endpoint file is treated as stale: the
    /// client must drop it and (when allowed) respawn at the
    /// version it speaks. Returning `Ok(None)` here is the
    /// "endpoint is stale, but caller said don't spawn" path that
    /// `daemon status` relies on.
    #[test]
    fn read_live_endpoint_treats_version_skew_as_stale() {
        let tmp = TempDir::new().unwrap();
        write_endpoint(
            tmp.path(),
            &EndpointState {
                version: MOUNT_PROTOCOL_VERSION + 99,
                host: "127.0.0.1".to_string(),
                port: 1,
                pid: Some(1),
            },
        );
        let endpoint_path = mount_daemon_endpoint_path(tmp.path());
        let result = read_live_endpoint(&endpoint_path).unwrap();
        assert!(
            result.is_none(),
            "version-skewed endpoint must be treated as stale"
        );
    }

    /// A live endpoint at the right version with a known-alive PID
    /// is returned as-is. We use init (PID 1) as the "definitely
    /// alive" sentinel; same trick the endpoint-module test uses.
    #[cfg(unix)]
    #[test]
    fn read_live_endpoint_returns_alive_endpoint() {
        let tmp = TempDir::new().unwrap();
        write_endpoint(
            tmp.path(),
            &EndpointState {
                version: MOUNT_PROTOCOL_VERSION,
                host: "127.0.0.1".to_string(),
                port: 9999,
                pid: Some(1),
            },
        );
        let endpoint_path = mount_daemon_endpoint_path(tmp.path());
        let result = read_live_endpoint(&endpoint_path).unwrap();
        assert!(result.is_some(), "alive endpoint must be returned as-is");
    }

    /// An endpoint pointing at a dead PID is treated as stale.
    /// 0x7fff_fffe is just below `i32::MAX` and never assigned.
    #[cfg(unix)]
    #[test]
    fn read_live_endpoint_detects_dead_pid() {
        let tmp = TempDir::new().unwrap();
        write_endpoint(
            tmp.path(),
            &EndpointState {
                version: MOUNT_PROTOCOL_VERSION,
                host: "127.0.0.1".to_string(),
                port: 9999,
                pid: Some(0x7fff_fffe),
            },
        );
        let endpoint_path = mount_daemon_endpoint_path(tmp.path());
        let result = read_live_endpoint(&endpoint_path).unwrap();
        assert!(result.is_none(), "endpoint with dead PID must be stale");
    }

    /// A missing endpoint file is silently treated as "no daemon
    /// running", not an error.
    #[test]
    fn read_live_endpoint_handles_missing_file() {
        let tmp = TempDir::new().unwrap();
        let endpoint_path = mount_daemon_endpoint_path(tmp.path());
        let result = read_live_endpoint(&endpoint_path).unwrap();
        assert!(result.is_none());
    }

    /// `sweep_stale_mounts` must remove the registry file even when
    /// every mount entry's `fusermount -u` fails (or is skipped on
    /// non-Linux). The contract is: leave nothing behind that a
    /// future CLI could re-process.
    #[test]
    fn sweep_stale_mounts_clears_registry_file() {
        let tmp = TempDir::new().unwrap();
        let registry_path = mount_daemon_registry_path(tmp.path());
        std::fs::create_dir_all(registry_path.parent().unwrap()).unwrap();
        let registry = MountRegistryFile {
            mounts: vec![PersistedMount {
                thread_id: "ghost".to_string(),
                mount_path: PathBuf::from("/nonexistent-mount-point"),
                pid: 1,
                since_ms: 0,
            }],
        };
        std::fs::write(
            &registry_path,
            serde_json::to_vec_pretty(&registry).unwrap(),
        )
        .unwrap();

        sweep_stale_mounts(tmp.path());
        assert!(
            !registry_path.exists(),
            "sweep must remove the registry file even when entries can't be unmounted"
        );
    }

    /// Sweeping a non-existent registry is a no-op (no panic, no
    /// error). Boring but load-bearing: every fresh repo hits this
    /// path on first daemon spawn.
    #[test]
    fn sweep_stale_mounts_is_noop_when_registry_absent() {
        let tmp = TempDir::new().unwrap();
        sweep_stale_mounts(tmp.path()); // must not panic
    }

    /// A v1 daemon binary that survived a CLI upgrade is observable
    /// only as a successful TCP connect followed by an undecodable
    /// response. After the decode failure the client must re-read the
    /// endpoint file, notice the recorded version is below
    /// `MOUNT_PROTOCOL_VERSION`, and surface a "stop the daemon" hint
    /// rather than a raw `decode helper response: ...` line.
    ///
    /// We simulate the race in the realistic order: the endpoint file
    /// initially advertises the current version (so
    /// `ensure_daemon_endpoint` accepts it), then the fake daemon
    /// "downgrades" the on-disk record to v1 just before sending a
    /// garbage reply — exactly what a v1 binary would have written
    /// had it been the one to claim the port.
    #[test]
    fn rpc_hints_at_stale_daemon_when_endpoint_version_is_older() {
        let tmp = TempDir::new().unwrap();
        let repo_root: PathBuf = tmp.path().to_path_buf();

        // Bind a real listener on a free port. A single accept loop
        // serves the one request the test sends — we don't validate
        // the bytes, we just write garbage back so JSON decoding
        // fails, which is exactly what a v1 daemon's reply looks like
        // to a v2-speaking CLI in the worst case.
        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
        let port = listener.local_addr().unwrap().port();
        let server_repo = repo_root.clone();
        let server = std::thread::spawn(move || {
            if let Ok((mut stream, _)) = listener.accept() {
                // Stamp the on-disk endpoint with the version a real
                // v1 daemon would have written, then send back
                // something that is not parseable as a
                // `MountDaemonResponse`. The client's decode failure
                // must therefore consult an endpoint file that
                // already reads as stale.
                let endpoint_path = mount_daemon_endpoint_path(&server_repo);
                persist_endpoint(
                    &endpoint_path,
                    &EndpointState {
                        version: MOUNT_PROTOCOL_VERSION - 1,
                        host: "127.0.0.1".to_string(),
                        port,
                        pid: Some(1),
                    },
                )
                .unwrap();
                let _ = stream.write_all(b"this is not json\n");
            }
        });

        write_endpoint(
            &repo_root,
            &EndpointState {
                version: MOUNT_PROTOCOL_VERSION,
                host: "127.0.0.1".to_string(),
                port,
                // Use init's PID (1) so `read_live_endpoint` accepts
                // the file as live and we exercise the decode path.
                pid: Some(1),
            },
        );

        let err = rpc(&repo_root, &MountDaemonRequest::Health {}, false)
            .expect_err("v1 daemon reply must surface as an error");
        let _ = server.join();

        // The hint must reference both the recorded daemon version
        // and the documented remediation.
        let chain = format!("{err:#}");
        assert!(
            chain.contains("heddled daemon is older"),
            "expected stale-daemon hint in error chain, got: {chain}"
        );
        assert!(
            chain.contains("heddle daemon stop"),
            "expected remediation hint in error chain, got: {chain}"
        );
        assert!(
            chain.contains(&format!("v{}", MOUNT_PROTOCOL_VERSION - 1)),
            "expected recorded daemon version in error chain, got: {chain}"
        );
        assert!(
            chain.contains(&format!("v{MOUNT_PROTOCOL_VERSION}")),
            "expected CLI version in error chain, got: {chain}"
        );
    }
}