Skip to main content

sqry_daemon/
error.rs

1//! Daemon-wide error type.
2//!
3//! Thin `thiserror` enum covering every fallible surface of the daemon:
4//! config loading, workspace lifecycle, admission control, IPC transport,
5//! rebuild dispatch, and lifecycle management (pidfile, signals, auto-start).
6//! Tasks 6–10 extend this enum as each surface lands.
7//! Every variant maps cleanly to a JSON-RPC error code when the error
8//! crosses the IPC boundary (see [`DaemonError::jsonrpc_code`]).
9//!
10//! # Exit-code mapping (Task 9 U1)
11//!
12//! Variants that can be returned before the IPC server binds (lifecycle errors)
13//! map to POSIX `sysexits.h` exit codes via [`DaemonError::exit_code`]:
14//!
15//! | Variant             | Exit code | `sysexits.h` constant  |
16//! |---------------------|-----------|------------------------|
17//! | `AlreadyRunning`    | 75        | `EX_TEMPFAIL`          |
18//! | `AutoStartTimeout`  | 69        | `EX_UNAVAILABLE`       |
19//! | `SignalSetup`       | 70        | `EX_SOFTWARE`          |
20//! | `Config`            | 78        | `EX_CONFIG`            |
21//! | `Io`                | 73        | `EX_CANTCREAT`         |
22//! | Other variants      | 70        | `EX_SOFTWARE` (default)|
23
24use std::{path::PathBuf, time::SystemTime};
25
26use thiserror::Error;
27
28use crate::{
29    JSONRPC_INTERNAL_ERROR, JSONRPC_INVALID_PARAMS, JSONRPC_MEMORY_BUDGET_EXCEEDED,
30    JSONRPC_TOOL_TIMEOUT, JSONRPC_WORKSPACE_BUILD_FAILED, JSONRPC_WORKSPACE_EVICTED,
31    JSONRPC_WORKSPACE_STALE_EXPIRED,
32};
33
34/// Result alias for daemon operations.
35pub type DaemonResult<T> = Result<T, DaemonError>;
36
37/// All daemon-surface error variants.
38#[derive(Debug, Error)]
39pub enum DaemonError {
40    /// Config file could not be read or parsed.
41    #[error("config error at {path}: {source}")]
42    Config {
43        path: PathBuf,
44        #[source]
45        source: anyhow::Error,
46    },
47
48    /// An `io::Error` occurred outside the config surface (socket bind,
49    /// pidfile lock, filesystem probe, etc.).
50    #[error(transparent)]
51    Io(#[from] std::io::Error),
52
53    /// Workspace load / rebuild failed with no prior-good graph to serve from.
54    ///
55    /// Maps to JSON-RPC `-32001`.
56    #[error("workspace {root} build failed: {reason}")]
57    WorkspaceBuildFailed { root: PathBuf, reason: String },
58
59    /// Workspace is in the Failed state and the most recent successful build
60    /// is older than the configured `stale_serve_max_age_hours` cap.
61    ///
62    /// Maps to JSON-RPC `-32002`.
63    #[error("workspace {root} stale-serve window expired ({age_hours}h >= {cap_hours}h cap)")]
64    WorkspaceStaleExpired {
65        root: PathBuf,
66        age_hours: u64,
67        cap_hours: u32,
68        /// Last successful build timestamp, if any. `None` when the workspace
69        /// has never successfully built (edge case: should not reach
70        /// `WorkspaceStaleExpired` in that case — `WorkspaceBuildFailed` is
71        /// returned instead — but the type is permissive for future-proofing).
72        last_good_at: Option<SystemTime>,
73        /// Textual diagnostic from the most recent failed build, if any.
74        last_error: Option<String>,
75    },
76
77    /// Admission control could not satisfy a reservation after evicting every
78    /// non-pinned workspace.
79    ///
80    /// Maps to JSON-RPC `-32003`.
81    #[error(
82        "memory budget exceeded: requested {requested_bytes} B, \
83         {current_bytes} B loaded + {reserved_bytes} B reserved + \
84         {retained_bytes} B retained / {limit_bytes} B limit"
85    )]
86    MemoryBudgetExceeded {
87        limit_bytes: u64,
88        current_bytes: u64,
89        reserved_bytes: u64,
90        retained_bytes: u64,
91        requested_bytes: u64,
92    },
93
94    /// Workspace was evicted or removed between a rebuild dispatch and its
95    /// admission / publish commit. Signals the Task 7b2 watcher task and any
96    /// direct `handle_changes` caller to terminate their per-workspace loop —
97    /// subsequent dispatches on the same `WorkspaceKey` must route through a
98    /// fresh `get_or_load` first.
99    ///
100    /// Surfaced by `RebuildDispatcher::handle_changes`' top-of-drain-loop
101    /// eviction gate AND by `WorkspaceManager::reserve_rebuild`'s Phase-1
102    /// `workspaces.read()` membership + cancellation check (both paths use
103    /// this typed variant so 7b2 can match on it without string parsing).
104    ///
105    /// Maps to JSON-RPC `-32004`.
106    #[error("workspace {root} evicted mid-rebuild")]
107    WorkspaceEvicted { root: PathBuf },
108
109    /// Caller requested `daemon/rebuild` or `daemon/cancel_rebuild` for a
110    /// path that is not currently registered in the `WorkspaceManager`.
111    ///
112    /// Shares the JSON-RPC `-32004` code with [`Self::WorkspaceEvicted`].
113    /// The `error_data` `"hint"` field distinguishes the two situations on
114    /// the wire.
115    ///
116    /// Maps to JSON-RPC `-32004`.
117    #[error("workspace {root} is not loaded")]
118    WorkspaceNotLoaded { root: PathBuf },
119
120    /// Tool invocation exceeded [`DaemonConfig::tool_timeout_secs`].
121    /// Emitted by `tool_core::classify_and_execute` (Task 8 Phase 8c U6)
122    /// when the `tokio::time::timeout(tool_timeout, spawn_blocking(run))`
123    /// outer timer fires. The detached [`tokio::task::JoinHandle`] is
124    /// dropped — the OS thread may continue executing the tool closure
125    /// but its result is discarded.
126    ///
127    /// The `deadline_ms` field is the canonical wire value (populated by
128    /// the constructor as `secs * 1000`) so `error_data` does not have
129    /// to re-derive it on every call and serialised payloads remain
130    /// byte-for-byte identical regardless of constructor shape.
131    ///
132    /// Maps to JSON-RPC `-32000`.
133    ///
134    /// [`DaemonConfig::tool_timeout_secs`]: crate::config::DaemonConfig
135    #[error(
136        "tool invocation exceeded deadline of {deadline_ms}ms for workspace {}",
137        root.display()
138    )]
139    ToolTimeout {
140        root: PathBuf,
141        secs: u64,
142        /// Derived: `secs * 1000`. Stored explicitly to avoid
143        /// re-calculating inside `error_data` / `Display` impls and to
144        /// give the MCP-path wrapper (`daemon_err_to_mcp`, Phase 8c U8)
145        /// a single field to read.
146        deadline_ms: u64,
147    },
148
149    /// Argument validation failure surfaced by `tool_core` BEFORE any
150    /// workspace classification runs. Used for `resolve_index_root`
151    /// failures, missing `path` arguments in MCP tool args, and any
152    /// other precondition violation that must be rejected with a
153    /// JSON-RPC `-32602` "Invalid params" response.
154    ///
155    /// Maps to JSON-RPC `-32602`.
156    #[error("invalid argument: {reason}")]
157    InvalidArgument { reason: String },
158
159    /// Catch-all for errors surfaced by
160    /// [`sqry_mcp::daemon_adapter`][1] tool execution that do not map
161    /// to a more specific `DaemonError` variant. The wrapped
162    /// `anyhow::Error` is flattened into a string on the wire via the
163    /// `Display`/`#[source]` chain.
164    ///
165    /// Maps to JSON-RPC `-32603`.
166    ///
167    /// [1]: https://docs.rs/sqry-mcp/latest/sqry_mcp/daemon_adapter/index.html
168    #[error("internal error: {0}")]
169    Internal(#[source] anyhow::Error),
170
171    // ── Task 9 U1 — lifecycle error variants ─────────────────────────────
172    /// A sqryd process already holds the exclusive flock on `lock` and has
173    /// written its PID to `pidfile`.  The caller should surface this to the
174    /// user with the owner PID (if legible) and exit `EX_TEMPFAIL` (75).
175    ///
176    /// This error fires before [`IpcServer::bind`] and therefore before any
177    /// workspace is registered; it should never be stored in the workspace
178    /// `last_error` field.  [`crate::workspace::manager::clone_err`] maps it
179    /// to `WorkspaceBuildFailed` as a defensive fallback.
180    ///
181    /// [`IpcServer::bind`]: crate::ipc::IpcServer
182    #[error(
183        "sqryd is already running (pid={}) on socket {} (lock: {})",
184        owner_pid.map_or_else(|| "?".to_owned(), |p| p.to_string()),
185        socket.display(),
186        lock.display()
187    )]
188    AlreadyRunning {
189        /// The IPC socket path that the running daemon owns.
190        socket: PathBuf,
191        /// The flock file that proves ownership.
192        lock: PathBuf,
193        /// PID of the owner process, if the pidfile was legible.
194        owner_pid: Option<u32>,
195    },
196
197    /// The daemon did not become ready within `timeout_secs` seconds.
198    /// Used by both the `--detach` parent wait loop and the
199    /// `lifecycle::start_detached` auto-spawn helper (Task 10).
200    ///
201    /// Callers should exit `EX_UNAVAILABLE` (69).
202    #[error(
203        "daemon did not become ready within {timeout_secs}s on socket {}",
204        socket.display()
205    )]
206    AutoStartTimeout {
207        /// How long we waited.
208        timeout_secs: u64,
209        /// The socket we polled.
210        socket: PathBuf,
211    },
212
213    /// Installing OS signal handlers failed (e.g. `sigaction` returned
214    /// `ENOSYS` in a highly-restricted container, or tokio's signal
215    /// registration failed).
216    ///
217    /// Callers should exit `EX_SOFTWARE` (70).
218    #[error("failed to install signal handlers: {source}")]
219    SignalSetup {
220        #[source]
221        source: std::io::Error,
222    },
223}
224
225impl DaemonError {
226    /// Map to the stable JSON-RPC error code used on the wire.
227    ///
228    /// Returns `None` for errors that have no public JSON-RPC code — these
229    /// are serialised as `-32603 "Internal error"` per the JSON-RPC 2.0 spec
230    /// at the IPC boundary (wired in Task 8).
231    ///
232    /// The Task 9 lifecycle variants (`AlreadyRunning`, `AutoStartTimeout`,
233    /// `SignalSetup`) fire before `IpcServer::bind` so they never cross the
234    /// IPC boundary directly; `None` is returned for them here.  They are
235    /// only surfaced to human users via `exit_code()` and process exit.
236    #[must_use]
237    pub const fn jsonrpc_code(&self) -> Option<i32> {
238        match self {
239            Self::WorkspaceBuildFailed { .. } => Some(JSONRPC_WORKSPACE_BUILD_FAILED),
240            Self::WorkspaceStaleExpired { .. } => Some(JSONRPC_WORKSPACE_STALE_EXPIRED),
241            Self::MemoryBudgetExceeded { .. } => Some(JSONRPC_MEMORY_BUDGET_EXCEEDED),
242            Self::WorkspaceEvicted { .. } | Self::WorkspaceNotLoaded { .. } => {
243                Some(JSONRPC_WORKSPACE_EVICTED)
244            }
245            Self::ToolTimeout { .. } => Some(JSONRPC_TOOL_TIMEOUT),
246            Self::InvalidArgument { .. } => Some(JSONRPC_INVALID_PARAMS),
247            Self::Internal(_) => Some(JSONRPC_INTERNAL_ERROR),
248            // Lifecycle errors don't cross the IPC boundary.
249            Self::AlreadyRunning { .. }
250            | Self::AutoStartTimeout { .. }
251            | Self::SignalSetup { .. }
252            | Self::Config { .. }
253            | Self::Io(_) => None,
254        }
255    }
256
257    /// Map to a POSIX process exit code following the BSD `sysexits.h`
258    /// conventions used for daemon CLI errors (Task 9 U1).
259    ///
260    /// | Code | Symbol        | Semantics                                   |
261    /// |------|---------------|---------------------------------------------|
262    /// | 0    | `EX_OK`       | Success (not an error; included for completeness) |
263    /// | 69   | `EX_UNAVAILABLE` | Service unavailable (timeout, not-ready)  |
264    /// | 70   | `EX_SOFTWARE` | Internal software error                     |
265    /// | 73   | `EX_CANTCREAT`| IO error / cannot create required file      |
266    /// | 75   | `EX_TEMPFAIL` | Try again (e.g. another instance is running)|
267    /// | 78   | `EX_CONFIG`   | Configuration error                         |
268    ///
269    /// For variants that only occur inside the IPC / workspace layer
270    /// (not at process-startup time) the JSON-RPC code's sign-flipped
271    /// magnitude is used as a proxy, falling back to `70` (`EX_SOFTWARE`)
272    /// for anything not covered.
273    #[must_use]
274    pub const fn exit_code(&self) -> u8 {
275        match self {
276            // BSD sysexits.h (man 3 sysexits) exit codes for lifecycle errors.
277            // 75 EX_TEMPFAIL: another process already owns the socket/lock.
278            Self::AlreadyRunning { .. } => 75,
279            // 69 EX_UNAVAILABLE: daemon didn't start in time.
280            Self::AutoStartTimeout { .. } => 69,
281            // 70 EX_SOFTWARE: internal OS-level failure (signal registration).
282            Self::SignalSetup { .. } => 70,
283            // 78 EX_CONFIG: malformed or unreadable config file.
284            Self::Config { .. } => 78,
285            // 73 EX_CANTCREAT: I/O failure (pidfile write, socket bind, etc.).
286            Self::Io(_) => 73,
287            // IPC-layer errors that escape to the CLI surface default to 70.
288            Self::WorkspaceBuildFailed { .. }
289            | Self::WorkspaceStaleExpired { .. }
290            | Self::MemoryBudgetExceeded { .. }
291            | Self::WorkspaceEvicted { .. }
292            | Self::WorkspaceNotLoaded { .. }
293            | Self::ToolTimeout { .. }
294            | Self::InvalidArgument { .. }
295            | Self::Internal(_) => 70,
296        }
297    }
298
299    /// Build the `error.data` JSON payload surfaced alongside the JSON-RPC
300    /// error code. Returns `None` when no structured payload should be
301    /// attached (typically `Io`/`Config` errors routed through `-32603`).
302    ///
303    /// Task 8 Phase 8a. The IPC method dispatch consumes this to populate
304    /// `JsonRpcError.data` so clients can render actionable diagnostics
305    /// without parsing the free-form `message` string.
306    #[must_use]
307    pub fn error_data(&self) -> Option<serde_json::Value> {
308        use serde_json::json;
309        match self {
310            Self::MemoryBudgetExceeded {
311                limit_bytes,
312                current_bytes,
313                reserved_bytes,
314                retained_bytes,
315                requested_bytes,
316            } => Some(json!({
317                "limit_bytes": limit_bytes,
318                "current_bytes": current_bytes,
319                "reserved_bytes": reserved_bytes,
320                "retained_bytes": retained_bytes,
321                "requested_bytes": requested_bytes,
322            })),
323            Self::WorkspaceStaleExpired {
324                root,
325                age_hours,
326                cap_hours,
327                last_good_at,
328                last_error,
329            } => {
330                // UTC-Zulu RFC3339 (`YYYY-MM-DDTHH:MM:SSZ`). `chrono` is
331                // already a workspace dependency used throughout the repo
332                // for RFC3339 rendering; `to_rfc3339_opts(Secs, true)`
333                // emits the UTC-Zulu form required by Task 7.
334                let last_good_rfc3339 = last_good_at.map(|t| {
335                    chrono::DateTime::<chrono::Utc>::from(t)
336                        .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
337                });
338                Some(json!({
339                    "root": root,
340                    "age_hours": age_hours,
341                    "cap_hours": cap_hours,
342                    "last_good_at": last_good_rfc3339,
343                    "last_error": last_error,
344                }))
345            }
346            Self::WorkspaceBuildFailed { root, reason } => Some(json!({
347                "root": root,
348                "reason": reason,
349            })),
350            Self::WorkspaceEvicted { root } => Some(json!({ "root": root })),
351            Self::WorkspaceNotLoaded { root } => Some(json!({
352                "root": root,
353                "hint": "use daemon/load to load the workspace before calling daemon/rebuild",
354            })),
355            // Phase 8c §O canonical 4-key envelope
356            // `{kind, retryable, retry_after_ms, details}` matching
357            // standalone `sqry-mcp::rpc_error_to_mcp` shape so clients
358            // can handle daemon-path and direct-path errors with a
359            // single parser.
360            Self::ToolTimeout {
361                root,
362                secs: _,
363                deadline_ms,
364            } => Some(json!({
365                "kind": "deadline_exceeded",
366                "retryable": true,
367                "retry_after_ms": 1000,
368                "details": {
369                    // `tool` is `null` here; the MCP-path wrapper
370                    // `daemon_err_to_mcp` (Phase 8c U8) populates it
371                    // with the method name pulled from the inbound
372                    // JSON-RPC request.
373                    "tool": serde_json::Value::Null,
374                    "deadline_ms": deadline_ms,
375                    "root": root.display().to_string(),
376                },
377            })),
378            Self::InvalidArgument { reason } => Some(json!({
379                "kind": "validation_error",
380                "retryable": false,
381                "retry_after_ms": serde_json::Value::Null,
382                "details": {
383                    "reason": reason,
384                },
385            })),
386            Self::Internal(_) => Some(json!({
387                "kind": "internal",
388                "retryable": false,
389                "retry_after_ms": serde_json::Value::Null,
390                "details": serde_json::Value::Null,
391            })),
392            Self::Io(_) | Self::Config { .. } => None,
393            // Lifecycle errors don't cross the IPC boundary; no structured
394            // payload is needed.
395            Self::AlreadyRunning { .. }
396            | Self::AutoStartTimeout { .. }
397            | Self::SignalSetup { .. } => None,
398        }
399    }
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    #[test]
407    fn jsonrpc_code_covers_every_public_variant() {
408        let mem = DaemonError::MemoryBudgetExceeded {
409            limit_bytes: 2_048 * 1024 * 1024,
410            current_bytes: 0,
411            reserved_bytes: 0,
412            retained_bytes: 0,
413            requested_bytes: 4_096 * 1024 * 1024,
414        };
415        assert_eq!(mem.jsonrpc_code(), Some(JSONRPC_MEMORY_BUDGET_EXCEEDED));
416
417        let stale = DaemonError::WorkspaceStaleExpired {
418            root: PathBuf::from("/repo"),
419            age_hours: 48,
420            cap_hours: 24,
421            last_good_at: None,
422            last_error: None,
423        };
424        assert_eq!(stale.jsonrpc_code(), Some(JSONRPC_WORKSPACE_STALE_EXPIRED));
425
426        let failed = DaemonError::WorkspaceBuildFailed {
427            root: PathBuf::from("/repo"),
428            reason: "plugin panic".into(),
429        };
430        assert_eq!(failed.jsonrpc_code(), Some(JSONRPC_WORKSPACE_BUILD_FAILED));
431
432        let evicted = DaemonError::WorkspaceEvicted {
433            root: PathBuf::from("/repo"),
434        };
435        assert_eq!(evicted.jsonrpc_code(), Some(JSONRPC_WORKSPACE_EVICTED));
436    }
437
438    #[test]
439    fn jsonrpc_code_is_none_for_internal_variants() {
440        let io = DaemonError::Io(std::io::Error::other("boom"));
441        assert!(io.jsonrpc_code().is_none());
442
443        let cfg = DaemonError::Config {
444            path: PathBuf::from("/etc/sqry.toml"),
445            source: anyhow::anyhow!("malformed"),
446        };
447        assert!(cfg.jsonrpc_code().is_none());
448    }
449
450    // -----------------------------------------------------------------
451    // Task 8 Phase 8c U5 — Tool-dispatch error variants
452    // -----------------------------------------------------------------
453    //
454    // These tests pin the stable wire contract defined in the design
455    // doc §O for `ToolTimeout` / `InvalidArgument` / `Internal`. Any
456    // change to the JSON-RPC codes or the `{kind, retryable,
457    // retry_after_ms, details}` envelope shape will fail at least one
458    // of these tests and force a matching update to the MCP-path
459    // wrapper (`daemon_err_to_mcp`) so daemon-path and direct-path
460    // MCP responses stay byte-identical.
461
462    #[test]
463    fn tool_timeout_has_jsonrpc_code_32000_and_deadline_exceeded_kind() {
464        let err = DaemonError::ToolTimeout {
465            root: PathBuf::from("/tmp/workspace"),
466            secs: 60,
467            deadline_ms: 60_000,
468        };
469        assert_eq!(err.jsonrpc_code(), Some(JSONRPC_TOOL_TIMEOUT));
470        assert_eq!(err.jsonrpc_code(), Some(-32000));
471        let data = err.error_data().expect("ToolTimeout must emit data");
472        assert_eq!(data["kind"], "deadline_exceeded");
473        assert_eq!(data["retryable"], true);
474        assert_eq!(data["retry_after_ms"], 1000);
475        assert_eq!(data["details"]["deadline_ms"], 60_000);
476        assert_eq!(data["details"]["root"], "/tmp/workspace");
477        // Placeholder for the MCP-path wrapper (Phase 8c U8) to
478        // overwrite with the inbound method name.
479        assert!(data["details"]["tool"].is_null());
480    }
481
482    #[test]
483    fn invalid_argument_has_jsonrpc_code_32602_and_validation_error_kind() {
484        let err = DaemonError::InvalidArgument {
485            reason: "missing path argument".into(),
486        };
487        assert_eq!(err.jsonrpc_code(), Some(JSONRPC_INVALID_PARAMS));
488        assert_eq!(err.jsonrpc_code(), Some(-32602));
489        let data = err.error_data().expect("InvalidArgument must emit data");
490        assert_eq!(data["kind"], "validation_error");
491        assert_eq!(data["retryable"], false);
492        assert!(data["retry_after_ms"].is_null());
493        assert_eq!(data["details"]["reason"], "missing path argument");
494    }
495
496    #[test]
497    fn internal_has_jsonrpc_code_32603_and_internal_kind() {
498        let err = DaemonError::Internal(anyhow::anyhow!("something blew up"));
499        assert_eq!(err.jsonrpc_code(), Some(JSONRPC_INTERNAL_ERROR));
500        assert_eq!(err.jsonrpc_code(), Some(-32603));
501        let data = err.error_data().expect("Internal must emit data");
502        assert_eq!(data["kind"], "internal");
503        assert_eq!(data["retryable"], false);
504        assert!(data["retry_after_ms"].is_null());
505        assert!(data["details"].is_null());
506    }
507
508    #[test]
509    fn error_data_envelope_shape_is_canonical_for_tool_dispatch_variants() {
510        // All 3 new Phase 8c U5 variants must emit EXACTLY the 4
511        // canonical top-level keys and no others — this is the
512        // contract documented in the design doc §O.3 and is what
513        // the MCP-path wrapper relies on to avoid renaming / reshaping
514        // fields.
515        let expected: std::collections::BTreeSet<String> =
516            ["kind", "retryable", "retry_after_ms", "details"]
517                .iter()
518                .map(|s| (*s).to_string())
519                .collect();
520
521        let errs = [
522            DaemonError::ToolTimeout {
523                root: PathBuf::from("/tmp"),
524                secs: 10,
525                deadline_ms: 10_000,
526            },
527            DaemonError::InvalidArgument { reason: "x".into() },
528            DaemonError::Internal(anyhow::anyhow!("y")),
529        ];
530        for err in errs {
531            let data = err.error_data().expect("variant must emit data");
532            let obj = data
533                .as_object()
534                .expect("error_data envelope must be a JSON object");
535            let keys: std::collections::BTreeSet<String> = obj.keys().cloned().collect();
536            assert_eq!(
537                keys, expected,
538                "error_data envelope for {err:?} must be exactly the 4 canonical keys"
539            );
540        }
541    }
542
543    // -----------------------------------------------------------------
544    // Task 9 U1 — DaemonError lifecycle variant tests
545    // -----------------------------------------------------------------
546
547    /// `AlreadyRunning` must have no JSON-RPC code (it never reaches the wire)
548    /// and must exit with code 75 (`EX_TEMPFAIL`).
549    #[test]
550    fn already_running_has_no_jsonrpc_code_and_exit_75() {
551        let err = DaemonError::AlreadyRunning {
552            owner_pid: Some(12345),
553            socket: PathBuf::from("/run/user/1000/sqryd.sock"),
554            lock: PathBuf::from("/run/user/1000/sqryd.lock"),
555        };
556        assert!(
557            err.jsonrpc_code().is_none(),
558            "AlreadyRunning must not carry a JSON-RPC code"
559        );
560        assert_eq!(
561            err.exit_code(),
562            75,
563            "AlreadyRunning must exit with EX_TEMPFAIL (75)"
564        );
565        assert!(
566            err.error_data().is_none(),
567            "AlreadyRunning must not carry IPC error_data"
568        );
569    }
570
571    /// `AlreadyRunning` with `owner_pid = None` must render `pid=?` in Display.
572    #[test]
573    fn already_running_owner_pid_none_display_contains_pid_question_mark() {
574        let err = DaemonError::AlreadyRunning {
575            owner_pid: None,
576            socket: PathBuf::from("/tmp/sqryd.sock"),
577            lock: PathBuf::from("/tmp/sqryd.lock"),
578        };
579        assert_eq!(err.exit_code(), 75);
580        assert!(err.jsonrpc_code().is_none());
581        let msg = err.to_string();
582        assert!(
583            msg.contains("pid=?"),
584            "Display for owner_pid=None must contain 'pid=?', got: {msg}"
585        );
586    }
587
588    /// `AutoStartTimeout` must have no JSON-RPC code and must exit with code
589    /// 69 (`EX_UNAVAILABLE`). The design doc iter-0 m5 explicitly changed this
590    /// from 73 (`EX_CANTCREAT`) to 69 (`EX_UNAVAILABLE`) — this test pins that
591    /// decision and guards against accidental reversion.
592    #[test]
593    fn auto_start_timeout_has_no_jsonrpc_code_and_exit_69_not_73() {
594        let err = DaemonError::AutoStartTimeout {
595            timeout_secs: 10,
596            socket: PathBuf::from("/run/user/1000/sqryd.sock"),
597        };
598        assert!(
599            err.jsonrpc_code().is_none(),
600            "AutoStartTimeout must not carry a JSON-RPC code"
601        );
602        assert_eq!(
603            err.exit_code(),
604            69,
605            "AutoStartTimeout must exit with EX_UNAVAILABLE (69), NOT EX_CANTCREAT (73)"
606        );
607        assert!(
608            err.error_data().is_none(),
609            "AutoStartTimeout must not carry IPC error_data"
610        );
611    }
612
613    /// `SignalSetup` must have no JSON-RPC code and must exit with code 70
614    /// (`EX_SOFTWARE`).
615    #[test]
616    fn signal_setup_has_no_jsonrpc_code_and_exit_70() {
617        let err = DaemonError::SignalSetup {
618            source: std::io::Error::other("SIGTERM handler failed"),
619        };
620        assert!(
621            err.jsonrpc_code().is_none(),
622            "SignalSetup must not carry a JSON-RPC code"
623        );
624        assert_eq!(
625            err.exit_code(),
626            70,
627            "SignalSetup must exit with EX_SOFTWARE (70)"
628        );
629        assert!(
630            err.error_data().is_none(),
631            "SignalSetup must not carry IPC error_data"
632        );
633    }
634
635    /// `Config` must exit with code 78 (`EX_CONFIG`).
636    #[test]
637    fn config_exits_with_78() {
638        let err = DaemonError::Config {
639            path: PathBuf::from("/etc/sqry/daemon.toml"),
640            source: anyhow::anyhow!("invalid TOML"),
641        };
642        assert_eq!(err.exit_code(), 78, "Config must exit with EX_CONFIG (78)");
643        assert!(err.jsonrpc_code().is_none());
644    }
645
646    /// `Io` must exit with code 73 (`EX_CANTCREAT`).
647    #[test]
648    fn io_error_exits_with_73() {
649        let err = DaemonError::Io(std::io::Error::other("socket bind failed"));
650        assert_eq!(err.exit_code(), 73, "Io must exit with EX_CANTCREAT (73)");
651        assert!(err.jsonrpc_code().is_none());
652    }
653
654    /// All IPC-path variants must have a defined exit code of 70 (the
655    /// `EX_SOFTWARE` default). They should never reach process exit, but the
656    /// method must be exhaustive.
657    #[test]
658    fn ipc_path_variants_exit_with_70_default() {
659        let cases: &[DaemonError] = &[
660            DaemonError::WorkspaceBuildFailed {
661                root: PathBuf::from("/repo"),
662                reason: "build failed".into(),
663            },
664            DaemonError::WorkspaceStaleExpired {
665                root: PathBuf::from("/repo"),
666                age_hours: 48,
667                cap_hours: 24,
668                last_good_at: None,
669                last_error: None,
670            },
671            DaemonError::MemoryBudgetExceeded {
672                limit_bytes: 1024 * 1024 * 1024,
673                current_bytes: 512 * 1024 * 1024,
674                reserved_bytes: 0,
675                retained_bytes: 0,
676                requested_bytes: 4 * 1024 * 1024 * 1024,
677            },
678            DaemonError::WorkspaceEvicted {
679                root: PathBuf::from("/repo"),
680            },
681            DaemonError::ToolTimeout {
682                root: PathBuf::from("/tmp/ws"),
683                secs: 60,
684                deadline_ms: 60_000,
685            },
686            DaemonError::InvalidArgument {
687                reason: "missing path".into(),
688            },
689            DaemonError::Internal(anyhow::anyhow!("internal error")),
690        ];
691        for err in cases {
692            assert_eq!(
693                err.exit_code(),
694                70,
695                "IPC-path variant {err:?} must default to EX_SOFTWARE (70)"
696            );
697        }
698    }
699
700    /// `clone_err` must handle all three Task 9 lifecycle variants without
701    /// panicking. All three collapse to `WorkspaceBuildFailed` (matching the
702    /// pattern for `Config`/`Io`) because they fire before `IpcServer::bind`
703    /// and should never reach workspace state storage — but the collapse must
704    /// preserve the human-readable message.
705    #[test]
706    fn clone_err_handles_lifecycle_variants_without_panic() {
707        use crate::workspace::manager::clone_err;
708
709        let ar = DaemonError::AlreadyRunning {
710            owner_pid: Some(42),
711            socket: PathBuf::from("/tmp/sqryd.sock"),
712            lock: PathBuf::from("/tmp/sqryd.lock"),
713        };
714        let cloned = clone_err(&ar);
715        assert!(
716            cloned.to_string().contains("sqryd.sock"),
717            "clone_err for AlreadyRunning must preserve socket path, got: {cloned}"
718        );
719
720        // Must not panic with owner_pid=None.
721        let ar_none = DaemonError::AlreadyRunning {
722            owner_pid: None,
723            socket: PathBuf::from("/tmp/sqryd.sock"),
724            lock: PathBuf::from("/tmp/sqryd.lock"),
725        };
726        let _ = clone_err(&ar_none);
727
728        let at = DaemonError::AutoStartTimeout {
729            timeout_secs: 15,
730            socket: PathBuf::from("/run/user/1000/sqryd.sock"),
731        };
732        let cloned = clone_err(&at);
733        assert!(
734            cloned.to_string().contains("15"),
735            "clone_err for AutoStartTimeout must preserve timeout_secs, got: {cloned}"
736        );
737
738        let ss = DaemonError::SignalSetup {
739            source: std::io::Error::other("SIGTERM handler failed"),
740        };
741        let cloned = clone_err(&ss);
742        assert!(
743            cloned.to_string().contains("SIGTERM handler failed"),
744            "clone_err for SignalSetup must preserve the source message via Display, got: {cloned}"
745        );
746    }
747
748    #[test]
749    fn clone_err_round_trips_tool_dispatch_variants() {
750        // `clone_err` lives in `workspace::manager` so it can be used
751        // by `classify_for_serve` to reproduce the stored
752        // `last_error` on every read path. The helper is
753        // `pub(crate)` so we exercise it directly from inside the
754        // daemon crate — Phase 8c U5 must keep all new variants
755        // round-trippable or `classify_for_serve` will collapse them
756        // into the generic `WorkspaceBuildFailed` fallback.
757        use crate::workspace::manager::clone_err;
758
759        let tt = DaemonError::ToolTimeout {
760            root: PathBuf::from("/tmp/workspace"),
761            secs: 60,
762            deadline_ms: 60_000,
763        };
764        let cloned = clone_err(&tt);
765        match cloned {
766            DaemonError::ToolTimeout {
767                root,
768                secs,
769                deadline_ms,
770            } => {
771                assert_eq!(root, PathBuf::from("/tmp/workspace"));
772                assert_eq!(secs, 60);
773                assert_eq!(deadline_ms, 60_000);
774            }
775            other => panic!("expected ToolTimeout round-trip, got {other:?}"),
776        }
777
778        let ia = DaemonError::InvalidArgument {
779            reason: "missing path argument".into(),
780        };
781        let cloned = clone_err(&ia);
782        match cloned {
783            DaemonError::InvalidArgument { reason } => {
784                assert_eq!(reason, "missing path argument");
785            }
786            other => panic!("expected InvalidArgument round-trip, got {other:?}"),
787        }
788
789        let inner = DaemonError::Internal(anyhow::anyhow!("something blew up"));
790        let cloned = clone_err(&inner);
791        match cloned {
792            DaemonError::Internal(err) => {
793                // `anyhow::Error` is not `Clone`; `clone_err`
794                // re-creates it from the `Display` representation so
795                // the user-facing message survives round-trips.
796                assert!(
797                    err.to_string().contains("something blew up"),
798                    "cloned Internal error must preserve the Display text, got: {err}"
799                );
800            }
801            other => panic!("expected Internal round-trip, got {other:?}"),
802        }
803    }
804}