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/// Builds the user-side prompt for one merge.
129pub fn prompt(old_content: &str, new_content: &str) -> String {
130    format!("--- VERSION A (currently stored) ---\n{old_content}\n\n--- VERSION B (incoming) ---\n{new_content}")
131}
132
133impl Merger {
134    /// Builds a merger around one binary and one timeout.
135    pub fn new(bin: impl Into<String>, timeout: Duration) -> Self {
136        Self {
137            bin: bin.into(),
138            timeout: if timeout.is_zero() {
139                DEFAULT_TIMEOUT
140            } else {
141                timeout
142            },
143        }
144    }
145
146    /// Returns a single reconciled version of the two inputs.
147    pub async fn merge(&self, old_content: &str, new_content: &str) -> Result<String, Error> {
148        let result = self
149            .ask(SYSTEM_PROMPT, &prompt(old_content, new_content))
150            .await?;
151
152        // A merge that comes back empty while both inputs had content is a
153        // malfunction, not a result — the model refused, or emitted nothing,
154        // or the envelope carried `is_error: false` over an empty body.
155        //
156        // Storing it would be the worst outcome this server can produce:
157        // both machines' notes replaced by "", reported as `merged: true`,
158        // and then written out as an empty file everywhere on the next pull.
159        // Every other failure here degrades to last-write-wins, and so does
160        // this one.
161        if result.trim().is_empty()
162            && !(old_content.trim().is_empty() && new_content.trim().is_empty())
163        {
164            return Err(Error::EmptyResult);
165        }
166        Ok(result)
167    }
168
169    /// One `claude -p` call: `system_prompt` in place of Claude Code's own,
170    /// `body` on stdin, and the answer's text. Every flag that keeps a merge
171    /// cheap and inert applies to any call made here: no tools, one turn,
172    /// nothing persisted, no MCP servers, a neutral working directory.
173    /// The evaluation's contradiction check asks through this too.
174    pub async fn ask(&self, system_prompt: &str, body: &str) -> Result<String, Error> {
175        let limit = if self.timeout.is_zero() {
176            DEFAULT_TIMEOUT
177        } else {
178            self.timeout
179        };
180
181        let mut cmd = Command::new(&self.bin);
182        cmd.arg("-p")
183            .args(["--output-format", "json"])
184            .args(["--input-format", "text"])
185            .args(["--system-prompt", system_prompt])
186            .arg("--exclude-dynamic-system-prompt-sections")
187            .arg("--strict-mcp-config")
188            // No tools, and one turn: a merge is text in and text out, so a
189            // model moved by something in the notes it was handed to act on
190            // them has nothing to act with, and stops there. Both flags were
191            // checked against the CLI version deploy/Dockerfile pins
192            // (CLAUDE_CODE_VERSION); `--max-turns` is a hidden option in
193            // its `--help`, so check the CLI itself before moving the pin.
194            .args(["--tools", ""])
195            .args(["--max-turns", "1"])
196            // Nothing kept in the CLI's session history: every merge holds
197            // two versions of a note, which have no business piling up
198            // beside the login.
199            .arg("--no-session-persistence")
200            // A neutral working directory: nothing here should read, or be
201            // influenced by, whatever project happens to be on disk. Along
202            // with the system prompt and MCP flags above this is the
203            // difference between a ~$0.01 and a ~$0.19 merge call.
204            .current_dir(std::env::temp_dir())
205            .stdin(Stdio::piped())
206            .stdout(Stdio::piped())
207            .stderr(Stdio::piped())
208            // If the timeout below drops this future, the child must die
209            // with it rather than linger holding a session.
210            .kill_on_drop(true);
211
212        let mut child = cmd.spawn().map_err(|e| match e.kind() {
213            std::io::ErrorKind::NotFound => {
214                Error::Unavailable(format!("{} not found on PATH", self.bin))
215            }
216            _ => Error::Failed(e.to_string()),
217        })?;
218
219        let mut stdin = child.stdin.take().expect("stdin was piped");
220        let body = body.to_string();
221        // Writing and draining run together: a prompt larger than the pipe
222        // buffer would deadlock if we wrote it all before reading output.
223        let run = async {
224            let write = async move {
225                stdin.write_all(body.as_bytes()).await?;
226                stdin.shutdown().await
227            };
228            let (written, out) = tokio::join!(write, child.wait_with_output());
229            // A child that closed stdin early shows up here as a broken
230            // pipe; its exit status and stderr say more about why than the
231            // write error does, so defer to them.
232            if let Err(e) = written {
233                if e.kind() != std::io::ErrorKind::BrokenPipe {
234                    return Err(e);
235                }
236            }
237            out.map(|o| (o.status, o.stdout, o.stderr))
238        };
239
240        let (status, stdout, stderr) = match timeout(limit, run).await {
241            Err(_) => return Err(Error::TimedOut(limit)),
242            Ok(Err(e)) => return Err(Error::Failed(e.to_string())),
243            Ok(Ok(v)) => v,
244        };
245
246        if !status.success() {
247            let detail = String::from_utf8_lossy(&stderr).trim().to_string();
248            let detail = if detail.is_empty() {
249                "(no stderr)".to_string()
250            } else {
251                detail
252            };
253            return Err(Error::Failed(format!(
254                "exit {}: {}",
255                status
256                    .code()
257                    .map_or_else(|| "signal".into(), |c| c.to_string()),
258                truncate(&detail, DETAIL_LIMIT)
259            )));
260        }
261
262        let out = String::from_utf8_lossy(&stdout);
263        let parsed: CliResult = serde_json::from_str(&out)
264            .map_err(|_| Error::NonJson(truncate(&out, DETAIL_LIMIT).to_string()))?;
265        if parsed.is_error {
266            return Err(Error::Rejected(
267                truncate(&parsed.result, DETAIL_LIMIT).to_string(),
268            ));
269        }
270        Ok(parsed.result)
271    }
272
273    /// Runs `claude auth status`, which is a local check and costs no
274    /// tokens. A missing binary is reported as unavailable, never a panic.
275    pub async fn check_status(&self) -> Status {
276        let checked_at = now();
277        let run = Command::new(&self.bin)
278            .args(["auth", "status"])
279            .stdin(Stdio::null())
280            .kill_on_drop(true)
281            .output();
282
283        let out = match timeout(STATUS_TIMEOUT, run).await {
284            Err(_) => {
285                return Status {
286                    checked_at,
287                    error: format!("`{} auth status` timed out", self.bin),
288                    ..Status::default()
289                }
290            }
291            Ok(Err(e)) => {
292                let error = if e.kind() == std::io::ErrorKind::NotFound {
293                    "claude CLI not found on PATH".to_string()
294                } else {
295                    e.to_string()
296                };
297                return Status {
298                    checked_at,
299                    error,
300                    ..Status::default()
301                };
302            }
303            Ok(Ok(out)) => out,
304        };
305
306        if !out.status.success() {
307            let detail = String::from_utf8_lossy(&out.stderr).trim().to_string();
308            return Status {
309                checked_at,
310                available: true,
311                logged_in: false,
312                error: if detail.is_empty() {
313                    "`claude auth status` failed".to_string()
314                } else {
315                    truncate(&detail, DETAIL_LIMIT).to_string()
316                },
317            };
318        }
319
320        match serde_json::from_slice::<AuthStatus>(&out.stdout) {
321            Ok(a) => Status {
322                checked_at,
323                available: true,
324                logged_in: a.logged_in,
325                error: String::new(),
326            },
327            Err(_) => Status {
328                checked_at,
329                available: true,
330                logged_in: false,
331                error: "could not parse `claude auth status` output".to_string(),
332            },
333        }
334    }
335}
336
337fn truncate(s: &str, n: usize) -> &str {
338    if s.len() <= n {
339        return s;
340    }
341    let mut end = n;
342    while end > 0 && !s.is_char_boundary(end) {
343        end -= 1;
344    }
345    &s[..end]
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    #[test]
353    fn prompt_labels_both_versions() {
354        let p = prompt("old", "new");
355        assert!(p.contains("--- VERSION A (currently stored) ---\nold"));
356        assert!(p.contains("--- VERSION B (incoming) ---\nnew"));
357    }
358
359    /// No API key may reach the CLI, and the cost-saving flags must stay.
360    #[test]
361    fn the_system_prompt_is_verbatim() {
362        assert!(SYSTEM_PROMPT.starts_with("You are a precise text-merging assistant"));
363        assert!(SYSTEM_PROMPT.ends_with("no code fences, nothing else."));
364    }
365
366    #[tokio::test]
367    async fn a_missing_binary_is_unavailable_not_a_panic() {
368        let m = Merger::new("definitely-not-a-real-binary", Duration::from_secs(1));
369        let st = m.check_status().await;
370        assert!(!st.available);
371        assert!(!st.logged_in);
372        assert!(!st.error.is_empty());
373        assert!(!st.checked_at.is_empty());
374
375        let err = m.merge("a", "b").await.unwrap_err();
376        assert!(matches!(err, Error::Unavailable(_)), "got {err:?}");
377    }
378
379    /// The envelope is what the CLI actually returns; `is_error` must be
380    /// treated as a failure even though the process exits 0.
381    #[tokio::test]
382    async fn parses_the_json_envelope_and_honors_is_error() {
383        let (_ok_dir, bin) = fake_claude(r#"{"is_error":false,"result":"merged!"}"#);
384        assert_eq!(
385            Merger::new(bin, Duration::from_secs(10))
386                .merge("a", "b")
387                .await
388                .unwrap(),
389            "merged!"
390        );
391
392        let (_bad_dir, bin) = fake_claude(r#"{"is_error":true,"result":"nope"}"#);
393        let err = Merger::new(bin, Duration::from_secs(10))
394            .merge("a", "b")
395            .await;
396        assert!(matches!(err, Err(Error::Rejected(_))), "got {err:?}");
397
398        let (_junk_dir, bin) = fake_claude("not json");
399        let err = Merger::new(bin, Duration::from_secs(10))
400            .merge("a", "b")
401            .await;
402        assert!(matches!(err, Err(Error::NonJson(_))), "got {err:?}");
403    }
404
405    /// The flags that keep a merge cheap and inert reach the CLI: no tools,
406    /// one turn, nothing persisted, and the prompt on stdin rather than in
407    /// the arguments.
408    #[tokio::test]
409    async fn the_merge_runs_with_no_tools_and_one_turn() {
410        use std::io::Write;
411        use std::os::unix::fs::PermissionsExt;
412
413        let dir = tempfile::tempdir().unwrap();
414        let args = dir.path().join("args");
415        let path = dir.path().join("claude");
416        let mut f = std::fs::File::create(&path).unwrap();
417        writeln!(f, "#!/bin/sh").unwrap();
418        writeln!(
419            f,
420            "for a in \"$@\"; do printf '%s\\n' \"$a\"; done > '{}'",
421            args.display()
422        )
423        .unwrap();
424        writeln!(f, "cat > /dev/null").unwrap();
425        writeln!(f, r#"printf '%s' '{{"is_error":false,"result":"merged"}}'"#).unwrap();
426        drop(f);
427        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
428        settle(&path);
429
430        Merger::new(path.to_str().unwrap(), Duration::from_secs(10))
431            .merge("old notes", "new notes")
432            .await
433            .unwrap();
434        let passed = std::fs::read_to_string(&args).unwrap();
435        let passed: Vec<&str> = passed.lines().collect();
436        let after = |flag: &str| {
437            passed
438                .iter()
439                .position(|a| *a == flag)
440                .and_then(|i| passed.get(i + 1).copied())
441        };
442        assert_eq!(after("--tools"), Some(""), "{passed:?}");
443        assert_eq!(after("--max-turns"), Some("1"), "{passed:?}");
444        assert_eq!(after("--output-format"), Some("json"), "{passed:?}");
445        for flag in [
446            "-p",
447            "--no-session-persistence",
448            "--strict-mcp-config",
449            "--exclude-dynamic-system-prompt-sections",
450        ] {
451            assert!(passed.contains(&flag), "{flag} missing from {passed:?}");
452        }
453        assert!(
454            !passed.iter().any(|a| a.contains("old notes")),
455            "the notes go on stdin, not in the arguments"
456        );
457    }
458
459    #[tokio::test]
460    async fn reads_logged_in_from_auth_status() {
461        let (_dir, bin) = fake_claude(r#"{"loggedIn":true}"#);
462        let st = Merger::new(bin, Duration::from_secs(10))
463            .check_status()
464            .await;
465        assert!(st.available && st.logged_in, "got {st:?}");
466    }
467
468    /// A stand-in `claude` that drains stdin, ignores its arguments and
469    /// prints `out`. The TempDir comes back so the caller keeps the script
470    /// alive for as long as it needs it.
471    fn fake_claude(out: &str) -> (tempfile::TempDir, String) {
472        use std::io::Write;
473        use std::os::unix::fs::PermissionsExt;
474
475        let dir = tempfile::tempdir().unwrap();
476        let path = dir.path().join("claude");
477        let mut f = std::fs::File::create(&path).unwrap();
478        writeln!(f, "#!/bin/sh").unwrap();
479        writeln!(f, "cat > /dev/null").unwrap();
480        writeln!(f, "printf '%s' '{out}'").unwrap();
481        drop(f);
482        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
483        settle(&path);
484        let bin = path.to_str().unwrap().to_string();
485        (dir, bin)
486    }
487
488    /// Waits out `ETXTBSY` on a script this process just wrote.
489    ///
490    /// Writing an executable and then running it is racy in a multi-threaded
491    /// program: another thread's `fork` inherits the still-open write handle
492    /// to this file, and until that child reaches its own `exec` the kernel
493    /// refuses to run the image — `Text file busy`. The window is
494    /// microseconds and only exists between a `File::create` here and some
495    /// other test's spawn, but with a dozen tests spawning processes in
496    /// parallel it does hit, and did: `cargo test --workspace` failed roughly
497    /// one run in four.
498    ///
499    /// The retry belongs here rather than in [`Merger`] because nothing in
500    /// production writes a binary and then runs it. This is an artifact of
501    /// building the stand-in inside the test binary.
502    fn settle(path: &std::path::Path) {
503        use std::process::Stdio;
504
505        for _ in 0..100 {
506            match std::process::Command::new(path)
507                .stdin(Stdio::null())
508                .stdout(Stdio::null())
509                .stderr(Stdio::null())
510                .status()
511            {
512                // Once it execs cleanly, no writer holds the inode and it
513                // will keep exec'ing — nothing reopens it for writing.
514                Ok(_) => return,
515                Err(e) if e.raw_os_error() == Some(libc_etxtbsy()) => {
516                    std::thread::sleep(Duration::from_millis(5));
517                }
518                // Anything else is a real problem the assertions should see.
519                Err(_) => return,
520            }
521        }
522        panic!("{} stayed ETXTBSY for half a second", path.display());
523    }
524
525    /// `ETXTBSY`. Spelled out rather than pulling in `libc` for one integer.
526    fn libc_etxtbsy() -> i32 {
527        26
528    }
529}