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