Skip to main content

recall_worker/
merge.rs

1//! Reconciles two versions of a memory file by shelling out to the local
2//! `claude` CLI.
3//!
4//! It lives in `recall-worker` because that is where merging is going: a
5//! worker drains the server's merge queue, so the `claude` login can leave
6//! the process that faces the internet. Until every deployment has a
7//! worker, `recall-server` still merges inline when none is enrolled, and
8//! uses this module for it, built without the worker's HTTP client (the
9//! `client` feature), so the server gains no client code.
10//!
11//! It shells out on purpose: no Anthropic API key appears anywhere in this
12//! codebase (see CLAUDE.md), so the merge rides whatever account is already
13//! logged into the CLI on the host. That is a real operational dependency,
14//! which is why every failure here degrades to last-write-wins rather than
15//! failing the sync — a not-yet-configured merge must never be able to take
16//! basic syncing down with it.
17
18use std::process::Stdio;
19use std::time::Duration;
20
21use serde::Deserialize;
22use tokio::io::AsyncWriteExt;
23use tokio::process::Command;
24use tokio::time::timeout;
25
26use crate::now;
27
28/// Replaces Claude Code's default agentic system prompt.
29///
30/// Together with `--exclude-dynamic-system-prompt-sections` and
31/// `--strict-mcp-config`, and running from a neutral working directory,
32/// this is what keeps a merge call cheap: measured live, the default prompt
33/// plus project context turned a trivial merge into roughly $0.19 of
34/// cache-creation tokens, versus about $0.01 with these. The task needs no
35/// tools and no project context — only text in, text out.
36pub const SYSTEM_PROMPT: &str = concat!(
37    "You are a precise text-merging assistant for a personal notes-sync tool. ",
38    "You merge two versions of a Claude Code auto-memory file that were edited independently on different machines and then synced through a central server. ",
39    "Rules: preserve every distinct fact from both versions; if both state the same fact in different words, keep it once, worded clearly (prefer the more complete wording); ",
40    "if they directly contradict each other, keep both and mark the conflict inline so a human can resolve it later; never invent information that isn't present in either version. ",
41    "Output ONLY the merged file content — no preamble, no explanation, no code fences, nothing else."
42);
43
44const DEFAULT_TIMEOUT: Duration = Duration::from_secs(45);
45const STATUS_TIMEOUT: Duration = Duration::from_secs(15);
46
47/// How much of a subprocess's output is worth quoting back in an error.
48const DETAIL_LIMIT: usize = 500;
49
50/// Why a merge could not produce a merged file. Every variant ends the same
51/// way at the call site: fall back to last-write-wins and keep the sync.
52#[derive(Debug, thiserror::Error)]
53pub enum Error {
54    /// The binary is missing, unspawnable, or not logged in.
55    #[error("claude CLI unavailable: {0}")]
56    Unavailable(String),
57    /// It ran, but not within `RECALL_MERGE_TIMEOUT_MS`.
58    #[error("claude merge timed out after {0:?}")]
59    TimedOut(Duration),
60    /// It exited non-zero.
61    #[error("claude failed: {0}")]
62    Failed(String),
63    /// Its output was not the JSON envelope this asked for.
64    #[error("claude returned non-JSON output: {0}")]
65    NonJson(String),
66    /// The envelope reported an error of its own.
67    #[error("claude merge failed: {0}")]
68    Rejected(String),
69    /// A well-formed success envelope carrying nothing, from two versions
70    /// that both had content.
71    ///
72    /// Treated as a failure precisely because it does not look like one:
73    /// stored as a result it would replace both machines' notes with `""`
74    /// and report `merged: true`.
75    #[error("claude returned an empty merge of two non-empty versions")]
76    EmptyResult,
77}
78
79/// Runs merges through a `claude` binary.
80#[derive(Debug, Clone)]
81pub struct Merger {
82    /// The binary to run. Resolved through `PATH` like any other command.
83    pub bin: String,
84    /// How long it may take before the merge is abandoned.
85    pub timeout: Duration,
86}
87
88impl Default for Merger {
89    fn default() -> Self {
90        Self {
91            bin: "claude".to_string(),
92            timeout: DEFAULT_TIMEOUT,
93        }
94    }
95}
96
97/// The local, zero-cost check of whether a merge could work at all: is the
98/// binary there, and is it logged in. Exposed via `/health` so a degraded
99/// deployment is visible without waiting for a real conflict.
100#[derive(Debug, Clone, Default, PartialEq, Eq)]
101pub struct Status {
102    /// When this check ran.
103    pub checked_at: String,
104    /// Whether the binary was found and runnable.
105    pub available: bool,
106    /// Whether it has a usable login.
107    pub logged_in: bool,
108    /// Why the check failed, when it did.
109    pub error: String,
110}
111
112/// The subset of `claude -p --output-format json` we read.
113#[derive(Debug, Deserialize)]
114struct CliResult {
115    #[serde(default)]
116    is_error: bool,
117    #[serde(default)]
118    result: String,
119}
120
121/// `claude auth status` speaks camelCase.
122#[derive(Debug, Deserialize)]
123struct AuthStatus {
124    #[serde(default, rename = "loggedIn")]
125    logged_in: bool,
126}
127
128/// Why a `claude` run that exited non-zero failed, in its own words.
129///
130/// With `--output-format json` the CLI reports most failures (not logged
131/// in, an API error, a usage limit) as a JSON envelope on stdout and
132/// nothing on stderr, so stderr alone reads as "(no stderr)" to whoever
133/// has to fix it. The envelope's `result` comes first when there is one,
134/// then stderr, then whatever stdout held.
135fn failure_detail(stdout: &[u8], stderr: &[u8]) -> String {
136    let out = String::from_utf8_lossy(stdout);
137    let said = serde_json::from_str::<CliResult>(out.trim())
138        .ok()
139        .map(|r| r.result.trim().to_string())
140        .filter(|r| !r.is_empty());
141    let err = String::from_utf8_lossy(stderr).trim().to_string();
142    match (said, err.is_empty(), out.trim().is_empty()) {
143        (Some(said), true, _) => said,
144        (Some(said), false, _) => format!("{said} ({err})"),
145        (None, false, _) => err,
146        (None, true, false) => out.trim().to_string(),
147        (None, true, true) => "no output on stdout or stderr".to_string(),
148    }
149}
150
151/// Builds the user-side prompt for one merge.
152pub fn prompt(old_content: &str, new_content: &str) -> String {
153    format!("--- VERSION A (currently stored) ---\n{old_content}\n\n--- VERSION B (incoming) ---\n{new_content}")
154}
155
156impl Merger {
157    /// Builds a merger around one binary and one timeout.
158    pub fn new(bin: impl Into<String>, timeout: Duration) -> Self {
159        Self {
160            bin: bin.into(),
161            timeout: if timeout.is_zero() {
162                DEFAULT_TIMEOUT
163            } else {
164                timeout
165            },
166        }
167    }
168
169    /// Returns a single reconciled version of the two inputs.
170    pub async fn merge(&self, old_content: &str, new_content: &str) -> Result<String, Error> {
171        let result = self
172            .ask(SYSTEM_PROMPT, &prompt(old_content, new_content))
173            .await?;
174
175        // A merge that comes back empty while both inputs had content is a
176        // malfunction, not a result — the model refused, or emitted nothing,
177        // or the envelope carried `is_error: false` over an empty body.
178        //
179        // Storing it would be the worst outcome this server can produce:
180        // both machines' notes replaced by "", reported as `merged: true`,
181        // and then written out as an empty file everywhere on the next pull.
182        // Every other failure here degrades to last-write-wins, and so does
183        // this one.
184        if result.trim().is_empty()
185            && !(old_content.trim().is_empty() && new_content.trim().is_empty())
186        {
187            return Err(Error::EmptyResult);
188        }
189        Ok(result)
190    }
191
192    /// One `claude -p` call: `system_prompt` in place of Claude Code's own,
193    /// `body` on stdin, and the answer's text. Every flag that keeps a merge
194    /// cheap and inert applies to any call made here: no tools, one turn,
195    /// nothing persisted, no MCP servers, a neutral working directory.
196    /// The evaluation's contradiction check asks through this too.
197    pub async fn ask(&self, system_prompt: &str, body: &str) -> Result<String, Error> {
198        let limit = if self.timeout.is_zero() {
199            DEFAULT_TIMEOUT
200        } else {
201            self.timeout
202        };
203
204        let mut cmd = Command::new(&self.bin);
205        cmd.arg("-p")
206            .args(["--output-format", "json"])
207            .args(["--input-format", "text"])
208            .args(["--system-prompt", system_prompt])
209            .arg("--exclude-dynamic-system-prompt-sections")
210            .arg("--strict-mcp-config")
211            // No tools, and one turn: a merge is text in and text out, so a
212            // model moved by something in the notes it was handed to act on
213            // them has nothing to act with, and stops there. Both flags were
214            // checked against the CLI version deploy/Dockerfile pins
215            // (CLAUDE_CODE_VERSION); `--max-turns` is a hidden option in
216            // its `--help`, so check the CLI itself before moving the pin.
217            .args(["--tools", ""])
218            .args(["--max-turns", "1"])
219            // Nothing kept in the CLI's session history: every merge holds
220            // two versions of a note, which have no business piling up
221            // beside the login.
222            .arg("--no-session-persistence")
223            // A neutral working directory: nothing here should read, or be
224            // influenced by, whatever project happens to be on disk. Along
225            // with the system prompt and MCP flags above this is the
226            // difference between a ~$0.01 and a ~$0.19 merge call.
227            .current_dir(std::env::temp_dir())
228            .stdin(Stdio::piped())
229            .stdout(Stdio::piped())
230            .stderr(Stdio::piped())
231            // If the timeout below drops this future, the child must die
232            // with it rather than linger holding a session.
233            .kill_on_drop(true);
234
235        let mut child = cmd.spawn().map_err(|e| match e.kind() {
236            std::io::ErrorKind::NotFound => {
237                Error::Unavailable(format!("{} not found on PATH", self.bin))
238            }
239            _ => Error::Failed(e.to_string()),
240        })?;
241
242        let mut stdin = child.stdin.take().expect("stdin was piped");
243        let body = body.to_string();
244        // Writing and draining run together: a prompt larger than the pipe
245        // buffer would deadlock if we wrote it all before reading output.
246        let run = async {
247            let write = async move {
248                stdin.write_all(body.as_bytes()).await?;
249                stdin.shutdown().await
250            };
251            let (written, out) = tokio::join!(write, child.wait_with_output());
252            // A child that closed stdin early shows up here as a broken
253            // pipe; its exit status and stderr say more about why than the
254            // write error does, so defer to them.
255            if let Err(e) = written {
256                if e.kind() != std::io::ErrorKind::BrokenPipe {
257                    return Err(e);
258                }
259            }
260            out.map(|o| (o.status, o.stdout, o.stderr))
261        };
262
263        let (status, stdout, stderr) = match timeout(limit, run).await {
264            Err(_) => return Err(Error::TimedOut(limit)),
265            Ok(Err(e)) => return Err(Error::Failed(e.to_string())),
266            Ok(Ok(v)) => v,
267        };
268
269        if !status.success() {
270            let detail = failure_detail(&stdout, &stderr);
271            return Err(Error::Failed(format!(
272                "exit {}: {}",
273                status
274                    .code()
275                    .map_or_else(|| "signal".into(), |c| c.to_string()),
276                truncate(&detail, DETAIL_LIMIT)
277            )));
278        }
279
280        let out = String::from_utf8_lossy(&stdout);
281        let parsed: CliResult = serde_json::from_str(&out)
282            .map_err(|_| Error::NonJson(truncate(&out, DETAIL_LIMIT).to_string()))?;
283        if parsed.is_error {
284            return Err(Error::Rejected(
285                truncate(&parsed.result, DETAIL_LIMIT).to_string(),
286            ));
287        }
288        Ok(parsed.result)
289    }
290
291    /// Runs `claude auth status`, which is a local check and costs no
292    /// tokens. A missing binary is reported as unavailable, never a panic.
293    pub async fn check_status(&self) -> Status {
294        let checked_at = now();
295        let run = Command::new(&self.bin)
296            .args(["auth", "status"])
297            .stdin(Stdio::null())
298            .kill_on_drop(true)
299            .output();
300
301        let out = match timeout(STATUS_TIMEOUT, run).await {
302            Err(_) => {
303                return Status {
304                    checked_at,
305                    error: format!("`{} auth status` timed out", self.bin),
306                    ..Status::default()
307                }
308            }
309            Ok(Err(e)) => {
310                let error = if e.kind() == std::io::ErrorKind::NotFound {
311                    "claude CLI not found on PATH".to_string()
312                } else {
313                    e.to_string()
314                };
315                return Status {
316                    checked_at,
317                    error,
318                    ..Status::default()
319                };
320            }
321            Ok(Ok(out)) => out,
322        };
323
324        if !out.status.success() {
325            // A CLI that is not logged in may say so as JSON on stdout and
326            // exit non-zero: that is an answer, not a failure to check.
327            if let Ok(a) = serde_json::from_slice::<AuthStatus>(&out.stdout) {
328                return Status {
329                    checked_at,
330                    available: true,
331                    logged_in: a.logged_in,
332                    error: String::new(),
333                };
334            }
335            let detail = String::from_utf8_lossy(&out.stderr).trim().to_string();
336            return Status {
337                checked_at,
338                available: true,
339                logged_in: false,
340                error: if detail.is_empty() {
341                    "`claude auth status` failed".to_string()
342                } else {
343                    truncate(&detail, DETAIL_LIMIT).to_string()
344                },
345            };
346        }
347
348        match serde_json::from_slice::<AuthStatus>(&out.stdout) {
349            Ok(a) => Status {
350                checked_at,
351                available: true,
352                logged_in: a.logged_in,
353                error: String::new(),
354            },
355            Err(_) => Status {
356                checked_at,
357                available: true,
358                logged_in: false,
359                error: "could not parse `claude auth status` output".to_string(),
360            },
361        }
362    }
363}
364
365fn truncate(s: &str, n: usize) -> &str {
366    if s.len() <= n {
367        return s;
368    }
369    let mut end = n;
370    while end > 0 && !s.is_char_boundary(end) {
371        end -= 1;
372    }
373    &s[..end]
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    #[test]
381    fn prompt_labels_both_versions() {
382        let p = prompt("old", "new");
383        assert!(p.contains("--- VERSION A (currently stored) ---\nold"));
384        assert!(p.contains("--- VERSION B (incoming) ---\nnew"));
385    }
386
387    /// No API key may reach the CLI, and the cost-saving flags must stay.
388    #[test]
389    fn the_system_prompt_is_verbatim() {
390        assert!(SYSTEM_PROMPT.starts_with("You are a precise text-merging assistant"));
391        assert!(SYSTEM_PROMPT.ends_with("no code fences, nothing else."));
392    }
393
394    #[tokio::test]
395    async fn a_missing_binary_is_unavailable_not_a_panic() {
396        let m = Merger::new("definitely-not-a-real-binary", Duration::from_secs(1));
397        let st = m.check_status().await;
398        assert!(!st.available);
399        assert!(!st.logged_in);
400        assert!(!st.error.is_empty());
401        assert!(!st.checked_at.is_empty());
402
403        let err = m.merge("a", "b").await.unwrap_err();
404        assert!(matches!(err, Error::Unavailable(_)), "got {err:?}");
405    }
406
407    /// The envelope is what the CLI actually returns; `is_error` must be
408    /// treated as a failure even though the process exits 0.
409    #[tokio::test]
410    async fn parses_the_json_envelope_and_honors_is_error() {
411        let (_ok_dir, bin) = fake_claude(r#"{"is_error":false,"result":"merged!"}"#);
412        assert_eq!(
413            Merger::new(bin, Duration::from_secs(10))
414                .merge("a", "b")
415                .await
416                .unwrap(),
417            "merged!"
418        );
419
420        let (_bad_dir, bin) = fake_claude(r#"{"is_error":true,"result":"nope"}"#);
421        let err = Merger::new(bin, Duration::from_secs(10))
422            .merge("a", "b")
423            .await;
424        assert!(matches!(err, Err(Error::Rejected(_))), "got {err:?}");
425
426        let (_junk_dir, bin) = fake_claude("not json");
427        let err = Merger::new(bin, Duration::from_secs(10))
428            .merge("a", "b")
429            .await;
430        assert!(matches!(err, Err(Error::NonJson(_))), "got {err:?}");
431    }
432
433    /// The flags that keep a merge cheap and inert reach the CLI: no tools,
434    /// one turn, nothing persisted, and the prompt on stdin rather than in
435    /// the arguments.
436    #[tokio::test]
437    async fn the_merge_runs_with_no_tools_and_one_turn() {
438        use std::io::Write;
439        use std::os::unix::fs::PermissionsExt;
440
441        let dir = tempfile::tempdir().unwrap();
442        let args = dir.path().join("args");
443        let path = dir.path().join("claude");
444        let mut f = std::fs::File::create(&path).unwrap();
445        writeln!(f, "#!/bin/sh").unwrap();
446        writeln!(
447            f,
448            "for a in \"$@\"; do printf '%s\\n' \"$a\"; done > '{}'",
449            args.display()
450        )
451        .unwrap();
452        writeln!(f, "cat > /dev/null").unwrap();
453        writeln!(f, r#"printf '%s' '{{"is_error":false,"result":"merged"}}'"#).unwrap();
454        drop(f);
455        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
456        settle(&path);
457
458        Merger::new(path.to_str().unwrap(), Duration::from_secs(10))
459            .merge("old notes", "new notes")
460            .await
461            .unwrap();
462        let passed = std::fs::read_to_string(&args).unwrap();
463        let passed: Vec<&str> = passed.lines().collect();
464        let after = |flag: &str| {
465            passed
466                .iter()
467                .position(|a| *a == flag)
468                .and_then(|i| passed.get(i + 1).copied())
469        };
470        assert_eq!(after("--tools"), Some(""), "{passed:?}");
471        assert_eq!(after("--max-turns"), Some("1"), "{passed:?}");
472        assert_eq!(after("--output-format"), Some("json"), "{passed:?}");
473        for flag in [
474            "-p",
475            "--no-session-persistence",
476            "--strict-mcp-config",
477            "--exclude-dynamic-system-prompt-sections",
478        ] {
479            assert!(passed.contains(&flag), "{flag} missing from {passed:?}");
480        }
481        assert!(
482            !passed.iter().any(|a| a.contains("old notes")),
483            "the notes go on stdin, not in the arguments"
484        );
485    }
486
487    #[tokio::test]
488    async fn reads_logged_in_from_auth_status() {
489        let (_dir, bin) = fake_claude(r#"{"loggedIn":true}"#);
490        let st = Merger::new(bin, Duration::from_secs(10))
491            .check_status()
492            .await;
493        assert!(st.available && st.logged_in, "got {st:?}");
494    }
495
496    /// The CLI reports "not logged in" and API errors as a JSON envelope on
497    /// stdout, with nothing on stderr: that is what a failure must say.
498    #[test]
499    fn a_failure_says_what_the_cli_said_on_stdout() {
500        let envelope = br#"{"type":"result","is_error":true,"result":"Invalid API key \u00b7 Please run /login"}"#;
501        assert_eq!(
502            failure_detail(envelope, b""),
503            "Invalid API key \u{b7} Please run /login"
504        );
505        assert_eq!(
506            failure_detail(envelope, b"warn: x"),
507            "Invalid API key \u{b7} Please run /login (warn: x)"
508        );
509        assert_eq!(failure_detail(b"", b"boom"), "boom");
510        assert_eq!(failure_detail(b"plain text\n", b""), "plain text");
511        assert_eq!(failure_detail(b"", b""), "no output on stdout or stderr");
512    }
513
514    /// A CLI that is not logged in may say so on stdout and exit 1: that is
515    /// a clear "no", not a failure to check.
516    #[tokio::test]
517    async fn a_not_logged_in_answer_with_a_failing_exit_is_still_an_answer() {
518        use std::os::unix::fs::PermissionsExt;
519        let dir = tempfile::tempdir().unwrap();
520        let path = dir.path().join("claude");
521        std::fs::write(&path, "#!/bin/sh\necho '{\"loggedIn\":false}'\nexit 1\n").unwrap();
522        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
523        settle(&path);
524        let st = Merger::new(path.to_str().unwrap(), Duration::from_secs(10))
525            .check_status()
526            .await;
527        assert!(st.available && !st.logged_in, "{st:?}");
528        assert!(st.error.is_empty(), "{st:?}");
529    }
530
531    /// A stand-in `claude` that drains stdin, ignores its arguments and
532    /// prints `out`. The TempDir comes back so the caller keeps the script
533    /// alive for as long as it needs it.
534    fn fake_claude(out: &str) -> (tempfile::TempDir, String) {
535        use std::io::Write;
536        use std::os::unix::fs::PermissionsExt;
537
538        let dir = tempfile::tempdir().unwrap();
539        let path = dir.path().join("claude");
540        let mut f = std::fs::File::create(&path).unwrap();
541        writeln!(f, "#!/bin/sh").unwrap();
542        writeln!(f, "cat > /dev/null").unwrap();
543        writeln!(f, "printf '%s' '{out}'").unwrap();
544        drop(f);
545        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
546        settle(&path);
547        let bin = path.to_str().unwrap().to_string();
548        (dir, bin)
549    }
550
551    /// Waits out `ETXTBSY` on a script this process just wrote.
552    ///
553    /// Writing an executable and then running it is racy in a multi-threaded
554    /// program: another thread's `fork` inherits the still-open write handle
555    /// to this file, and until that child reaches its own `exec` the kernel
556    /// refuses to run the image — `Text file busy`. The window is
557    /// microseconds and only exists between a `File::create` here and some
558    /// other test's spawn, but with a dozen tests spawning processes in
559    /// parallel it does hit, and did: `cargo test --workspace` failed roughly
560    /// one run in four.
561    ///
562    /// The retry belongs here rather than in [`Merger`] because nothing in
563    /// production writes a binary and then runs it. This is an artifact of
564    /// building the stand-in inside the test binary.
565    fn settle(path: &std::path::Path) {
566        use std::process::Stdio;
567
568        for _ in 0..100 {
569            match std::process::Command::new(path)
570                .stdin(Stdio::null())
571                .stdout(Stdio::null())
572                .stderr(Stdio::null())
573                .status()
574            {
575                // Once it execs cleanly, no writer holds the inode and it
576                // will keep exec'ing — nothing reopens it for writing.
577                Ok(_) => return,
578                Err(e) if e.raw_os_error() == Some(libc_etxtbsy()) => {
579                    std::thread::sleep(Duration::from_millis(5));
580                }
581                // Anything else is a real problem the assertions should see.
582                Err(_) => return,
583            }
584        }
585        panic!("{} stayed ETXTBSY for half a second", path.display());
586    }
587
588    /// `ETXTBSY`. Spelled out rather than pulling in `libc` for one integer.
589    fn libc_etxtbsy() -> i32 {
590        26
591    }
592}