Skip to content

Commit 27b1e94

Browse files
committed
Share chat history across git worktrees of the same repo
Canonicalize linked worktrees to the main worktree root when keying the workspace cache and fold old per-worktree cache dirs in on first open. Since pooling makes concurrent servers on the same cache file common, writes now read+merge under a cross process file lock instead of overwriting, with deleted chats tombstoned so merges don't resurrect them. Fixes #558
1 parent d290747 commit 27b1e94

6 files changed

Lines changed: 381 additions & 60 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- Share chat history across git worktrees of the same repo, and merge workspace cache writes from concurrent servers instead of overwriting. #558
6+
57
## 0.151.1
68

79
- Fix git tool heredoc failures for messages with apostrophes on old shells (e.g. macOS bash 3.2); git tool now honors `toolCall.shellCommand` path/args config. #562

src/eca/cache.clj

Lines changed: 80 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -40,26 +40,86 @@
4040
(io/file (user-home) ".cache"))]
4141
(io/file cache-home "eca")))
4242

43+
(defn ^:private linked-worktree-root*
44+
"When `path` is the root of a *linked* git worktree, returns the repository's
45+
main worktree root as a string; otherwise nil.
46+
47+
Resolved from the filesystem alone (no `git` subprocess, no PATH dependency
48+
in the native image): a linked worktree has a `.git` *file* containing a
49+
`gitdir:` pointer to `<main>/.git/worktrees/<name>`, whose `commondir` file
50+
points back to the shared `.git` directory; the main worktree root is that
51+
directory's parent. Anything else (normal clone, bare repo, submodule
52+
`.git` file, broken layout) yields nil."
53+
[^String path]
54+
(try
55+
(let [dot-git (fs/file path ".git")]
56+
(when (fs/regular-file? dot-git)
57+
(when-let [gitdir-str (some #(second (re-find #"^gitdir:\s*(.+?)\s*$" %))
58+
(fs/read-all-lines dot-git))]
59+
(let [gitdir (fs/normalize (if (fs/relative? (fs/path gitdir-str))
60+
(fs/absolutize (fs/path path gitdir-str))
61+
(fs/path gitdir-str)))
62+
commondir-file (fs/file (str gitdir) "commondir")]
63+
(when (fs/regular-file? commondir-file)
64+
(when-let [common-str (some->> (fs/read-all-lines commondir-file)
65+
(remove string/blank?)
66+
(first)
67+
(string/trim))]
68+
(let [common (fs/normalize (if (fs/relative? (fs/path common-str))
69+
(fs/absolutize (fs/path gitdir common-str))
70+
(fs/path common-str)))]
71+
(when (and (= ".git" (fs/file-name common))
72+
(fs/directory? common))
73+
(some-> (fs/parent common) str)))))))))
74+
(catch Throwable _ nil)))
75+
76+
(def ^:private linked-worktree-root
77+
"Memoized `linked-worktree-root*`: resolved on nearly every chat mutation via
78+
`eca.db/update-workspaces-cache!`, and a worktree's gitdir pointer does not
79+
change during a server's lifetime."
80+
(memoize linked-worktree-root*))
81+
82+
(defn ^:private canonicalize-workspace-path
83+
"Canonical identity path for a workspace folder: linked git worktrees map to
84+
their repository's main worktree root so chat history is shared across all
85+
worktrees of a repo (#558). Anything else returns unchanged, keeping
86+
existing cache keys byte-identical."
87+
[path]
88+
(or (linked-worktree-root path) path))
89+
90+
(defn ^:private raw-workspace-paths
91+
"Absolute workspace paths as reported by the editor, without worktree
92+
canonicalization."
93+
[workspaces uri->filename-fn]
94+
(map #(str (fs/absolutize (fs/file (uri->filename-fn (:uri %))))) workspaces))
95+
4396
(defn ^:private sorted-workspace-paths
44-
"Absolute workspace paths, de-duplicated and sorted, so the result is stable
45-
regardless of the order the editor reports its workspace folders."
97+
"Absolute canonical workspace paths, de-duplicated and sorted, so the result
98+
is stable regardless of the order the editor reports its workspace folders."
4699
[workspaces uri->filename-fn]
47-
(->> workspaces
48-
(map #(str (fs/absolutize (fs/file (uri->filename-fn (:uri %))))))
100+
(->> (raw-workspace-paths workspaces uri->filename-fn)
101+
(map canonicalize-workspace-path)
49102
(distinct)
50103
(sort)))
51104

52-
(defn workspaces-hash
53-
"Returns an 8-char base64 (URL-safe, no padding) hash key for the given workspace set.
54-
Order-independent: the same set of folders always yields the same hash."
55-
[workspaces uri->filename-fn]
56-
(let [joined (string/join ":" (sorted-workspace-paths workspaces uri->filename-fn))
105+
(defn ^:private paths-hash
106+
"8-char base64 (URL-safe, no padding) hash key for a seq of paths."
107+
[paths]
108+
(let [joined (string/join ":" paths)
57109
digest-bytes (digest/sha-256-bytes joined)
58110
encoder (-> (java.util.Base64/getUrlEncoder)
59111
(.withoutPadding))
60112
key (.encodeToString encoder digest-bytes)]
61113
(subs key 0 (min 8 (count key)))))
62114

115+
(defn workspaces-hash
116+
"Returns an 8-char base64 (URL-safe, no padding) hash key for the given workspace set.
117+
Order-independent: the same set of folders always yields the same hash.
118+
Linked git worktrees are canonicalized to their main worktree root, so every
119+
worktree of a repository shares the same hash as the repository itself."
120+
[workspaces uri->filename-fn]
121+
(paths-hash (sorted-workspace-paths workspaces uri->filename-fn)))
122+
63123
(def ^:private logger-tag "[CACHE]")
64124

65125
(def ^:private max-prefix-length 30)
@@ -93,18 +153,24 @@
93153
(defn redundant-workspace-cache-files
94154
"Returns the cache files named `filename` that live in directories belonging to
95155
the same workspace set as the canonical dir but under a different name - i.e.
96-
legacy hash-only dirs, or dirs prefixed from a different folder order. The
97-
canonical dir is excluded. Used to heal fragmented chat caches."
156+
legacy hash-only dirs, dirs prefixed from a different folder order, or dirs
157+
keyed by the pre-worktree-canonicalization hash (chats saved when a linked
158+
worktree was its own history bucket). The canonical dir is excluded. Used to
159+
heal fragmented chat caches."
98160
[workspaces filename uri->filename-fn]
99-
(let [hash (workspaces-hash workspaces uri->filename-fn)
161+
(let [canonical-hash (workspaces-hash workspaces uri->filename-fn)
162+
raw-hash (paths-hash (sort (distinct (raw-workspace-paths workspaces uri->filename-fn))))
163+
hashes (cond-> #{canonical-hash}
164+
(not= raw-hash canonical-hash) (conj raw-hash))
100165
canonical-dir-name (workspace-dir-name workspaces uri->filename-fn)
101166
base (global-dir)]
102167
(if (fs/exists? base)
103168
(->> (fs/list-dir base)
104169
(filter fs/directory?)
105170
(map #(str (fs/file-name %)))
106-
(filter (fn [n] (or (= n hash)
107-
(string/ends-with? n (str "_" hash)))))
171+
(filter (fn [n] (some (fn [h] (or (= n h)
172+
(string/ends-with? n (str "_" h))))
173+
hashes)))
108174
(remove #(= % canonical-dir-name))
109175
(map #(io/file base % filename))
110176
(filter fs/exists?)

src/eca/db.clj

Lines changed: 125 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010
(:import
1111
[java.io OutputStream RandomAccessFile]
1212
[java.nio.channels FileChannel FileLock]
13-
[java.nio.file AtomicMoveNotSupportedException CopyOption Files StandardCopyOption]
14-
[java.nio.file.attribute FileAttribute]
13+
[java.nio.file AtomicMoveNotSupportedException CopyOption Files LinkOption StandardCopyOption]
14+
[java.nio.file.attribute BasicFileAttributes FileAttribute]
1515
[java.util.concurrent ConcurrentHashMap]))
1616

1717
(set! *warn-on-reflection* true)
@@ -31,6 +31,9 @@
3131
:providers-config-hash :string
3232
:last-config-notified ::any-map
3333
:stopping :boolean
34+
;; chat ids deleted in this session; excluded from workspace cache writes so
35+
;; the merge-on-write never resurrects them from a shared cache file.
36+
:deleted-chat-ids #{:string}
3437
:models {"<model-name>" {:web-search :boolean
3538
:tools :boolean
3639
:reason? :boolean
@@ -150,6 +153,8 @@
150153
;; {tool-name {:remember-to-approve? boolean
151154
;; :remembered-command-keys #{string}}}
152155
:tool-calls {}
156+
;; Chat ids deleted in this session (not cached), see _db-spec.
157+
:deleted-chat-ids #{}
153158

154159
;; cacheable, bump db `version` when changing any below
155160
:chats {}
@@ -305,25 +310,91 @@
305310
{}
306311
chat-maps))
307312

313+
(defn ^:private with-os-file-lock-fn
314+
"Run `f` while holding both a JVM monitor for `lock-file` and an OS advisory
315+
exclusive lock on it. The JVM monitor avoids `OverlappingFileLockException`
316+
when two threads in the same ECA server race; the file lock serializes
317+
across `eca server` processes that share the same cache dir. Blocks until
318+
both are acquired."
319+
[^java.io.File lock-file f]
320+
;; `file-lock` interns the lock object in `file-locks`, so it is
321+
;; not actually local to this scope; suppress the false positive.
322+
#_{:clj-kondo/ignore [:locking-suspicious-lock]}
323+
(locking (file-lock lock-file)
324+
(io/make-parents lock-file)
325+
(let [^RandomAccessFile raf (RandomAccessFile. lock-file "rw")
326+
^FileChannel channel (.getChannel raf)
327+
lock-ref (volatile! nil)]
328+
(try
329+
(vreset! lock-ref ^FileLock (.lock channel))
330+
(f)
331+
(finally
332+
(when-let [^FileLock lock @lock-ref]
333+
(try (.release lock)
334+
(catch Throwable e
335+
(logger/warn logger-tag "Could not release cache lock" e))))
336+
(try (.close channel) (catch Throwable _))
337+
(try (.close raf) (catch Throwable _)))))))
338+
339+
(defn ^:private workspace-cache-lock-file ^java.io.File [^java.io.File cache-file]
340+
(io/file (str (.getPath cache-file) ".lock")))
341+
342+
(defonce ^:private ^ConcurrentHashMap last-workspace-write-attrs (ConcurrentHashMap.))
343+
344+
(defn ^:private cache-file-attrs
345+
"Returns [last-modified-time size] for `f`, or nil when it does not exist."
346+
[^java.io.File f]
347+
(try
348+
(when (.exists f)
349+
(let [^BasicFileAttributes attrs (Files/readAttributes
350+
(.toPath f)
351+
BasicFileAttributes
352+
^"[Ljava.nio.file.LinkOption;" (into-array LinkOption []))]
353+
[(.lastModifiedTime attrs) (.size attrs)]))
354+
(catch Throwable _ nil)))
355+
356+
(defn ^:private record-workspace-write-attrs!
357+
"Remembers the on-disk attributes of `f` right after this process wrote it,
358+
so the next write can cheaply detect whether another process wrote in
359+
between (see `workspace-cache-changed-on-disk?`)."
360+
[^java.io.File f]
361+
(if-let [attrs (cache-file-attrs f)]
362+
(.put last-workspace-write-attrs (.getAbsolutePath f) attrs)
363+
(.remove last-workspace-write-attrs (.getAbsolutePath f))))
364+
365+
(defn ^:private workspace-cache-changed-on-disk?
366+
"True when `f` exists with different attributes than the last write this
367+
process made to it - i.e. another process wrote it (or this process never
368+
wrote it yet), so its content must be merged instead of overwritten."
369+
[^java.io.File f]
370+
(let [attrs (cache-file-attrs f)]
371+
(boolean (and attrs (not= attrs (.get last-workspace-write-attrs (.getAbsolutePath f)))))))
372+
308373
(defn consolidate-workspace-cache!
309374
"Heals chat caches that fragmented across multiple directories for the same
310-
workspace set (legacy hash-only dirs, or dirs prefixed from a different folder
311-
order). Merges every matching cache into the canonical dir (newest chat wins)
312-
and removes the redundant dirs. Best-effort and idempotent."
375+
workspace set (legacy hash-only dirs, dirs prefixed from a different folder
376+
order, or per-worktree dirs from before worktree canonicalization). Merges
377+
every matching cache into the canonical dir (newest chat wins) and removes
378+
the redundant dirs. Best-effort and idempotent; runs under the workspace
379+
cache file lock so it cannot race writes from another live server."
313380
[workspaces metrics]
314381
(try
315382
(let [redundant (cache/redundant-workspace-cache-files workspaces "db.transit.json" shared/uri->filename)]
316383
(when (seq redundant)
317-
(let [canonical (transit-global-by-workspaces-db-file workspaces)
318-
caches (keep #(read-cache % metrics) (cons canonical redundant))
319-
merged (merge-chats (map :chats caches))]
320-
(logger/info logger-tag (str "Consolidating " (count redundant) " redundant workspace cache dir(s) into " canonical))
321-
(upsert-cache! {:chats merged :version version} canonical metrics)
322-
(doseq [^java.io.File f redundant]
323-
(try
324-
(fs/delete-tree (.getParentFile f))
325-
(catch Throwable e
326-
(logger/warn logger-tag (str "Could not remove redundant cache dir " (.getParentFile f)) e)))))))
384+
(let [canonical (transit-global-by-workspaces-db-file workspaces)]
385+
(with-os-file-lock-fn
386+
(workspace-cache-lock-file canonical)
387+
(fn []
388+
(let [caches (keep #(read-cache % metrics) (cons canonical redundant))
389+
merged (merge-chats (map :chats caches))]
390+
(logger/info logger-tag (str "Consolidating " (count redundant) " redundant workspace cache dir(s) into " canonical))
391+
(upsert-cache! {:chats merged :version version} canonical metrics)
392+
(record-workspace-write-attrs! canonical)
393+
(doseq [^java.io.File f redundant]
394+
(try
395+
(fs/delete-tree (.getParentFile f))
396+
(catch Throwable e
397+
(logger/warn logger-tag (str "Could not remove redundant cache dir " (.getParentFile f)) e))))))))))
327398
(catch Throwable e
328399
(logger/warn logger-tag "Could not consolidate workspace cache" e))))
329400

@@ -363,19 +434,42 @@
363434
(defn ^:private normalize-db-for-global-write [db]
364435
(select-keys db [:auth :mcp-auth]))
365436

366-
(defn update-workspaces-cache! [db metrics]
367-
(-> (normalize-db-for-workspace-write db)
368-
(assoc :version version)
369-
(upsert-cache! (transit-global-by-workspaces-db-file (or (:initial-workspace-folders db)
370-
(:workspace-folders db))) metrics)))
437+
(defn update-workspaces-cache!
438+
"Persists the workspace-scoped db slice (chats) to the workspace cache file.
439+
440+
Safe across processes: multiple ECA servers can share one cache file (e.g. a
441+
repo and its worktrees, #558), so when the file changed on disk since this
442+
process last wrote it, the on-disk chats are merged in (newest wins, ties
443+
keep the in-memory copy) instead of blindly overwritten. Chats deleted in
444+
this session (`:deleted-chat-ids`) are never resurrected by the merge. Runs
445+
under a cross-process file lock; if locking fails it falls back to a plain
446+
overwrite (previous behavior)."
447+
[db metrics]
448+
(let [dest (transit-global-by-workspaces-db-file (or (:initial-workspace-folders db)
449+
(:workspace-folders db)))
450+
payload (-> (normalize-db-for-workspace-write db)
451+
(assoc :version version))
452+
deleted-ids (not-empty (:deleted-chat-ids db))]
453+
(try
454+
(with-os-file-lock-fn
455+
(workspace-cache-lock-file dest)
456+
(fn []
457+
(let [disk-chats (when (workspace-cache-changed-on-disk? dest)
458+
(:chats (read-cache dest metrics)))
459+
chats (cond-> (:chats payload)
460+
(seq disk-chats) (as-> $ (merge-chats [$ disk-chats]))
461+
deleted-ids (as-> $ (apply dissoc $ deleted-ids)))]
462+
(upsert-cache! (assoc payload :chats chats) dest metrics)
463+
(record-workspace-write-attrs! dest))))
464+
(catch Throwable e
465+
(logger/warn logger-tag (str "Workspace cache lock failed, writing without merge: " (ex-message e)))
466+
(upsert-cache! payload dest metrics)))))
371467

372468
(defn update-global-cache! [db metrics]
373469
(-> (normalize-db-for-global-write db)
374470
(assoc :version version)
375471
(upsert-cache! (transit-global-db-file) metrics)))
376472

377-
(def ^:private global-cache-lock-sentinel (Object.))
378-
379473
(defn ^:private global-cache-lock-file []
380474
(io/file (cache/global-dir) "db.transit.json.lock"))
381475

@@ -386,22 +480,7 @@
386480
race a renew; the file lock serializes across `eca server` processes
387481
that share `~/.cache/eca/`. Blocks until both are acquired."
388482
[f]
389-
(locking global-cache-lock-sentinel
390-
(let [^java.io.File lock-file (global-cache-lock-file)
391-
_ (io/make-parents lock-file)
392-
^RandomAccessFile raf (RandomAccessFile. lock-file "rw")
393-
^FileChannel channel (.getChannel raf)
394-
lock-ref (volatile! nil)]
395-
(try
396-
(vreset! lock-ref ^FileLock (.lock channel))
397-
(f)
398-
(finally
399-
(when-let [^FileLock lock @lock-ref]
400-
(try (.release lock)
401-
(catch Throwable e
402-
(logger/warn logger-tag "Could not release global cache lock" e))))
403-
(try (.close channel) (catch Throwable _))
404-
(try (.close raf) (catch Throwable _)))))))
483+
(with-os-file-lock-fn (global-cache-lock-file) f))
405484

406485
(defmacro with-global-cache-lock
407486
"See `with-global-cache-lock-fn`. Runs `body` while holding the lock."
@@ -438,16 +517,19 @@
438517
(when (pos? retention-days)
439518
(let [retention-ms (* retention-days 24 60 60 1000)
440519
cutoff (- (System/currentTimeMillis) retention-ms)
441-
removed (atom 0)]
520+
removed-ids* (atom #{})]
442521
(swap! db* update :chats
443522
(fn [chats]
444523
(into {}
445-
(filter (fn [[_id chat]]
524+
(filter (fn [[id chat]]
446525
(let [created-at (:created-at chat)]
447526
(if (and created-at (< created-at cutoff))
448-
(do (swap! removed inc) false)
527+
(do (swap! removed-ids* conj id) false)
449528
true))))
450529
chats)))
451-
(when (pos? @removed)
452-
(logger/info logger-tag (str "Cleaned up " @removed " chat(s) older than " retention-days " days"))
530+
(when-let [removed-ids (not-empty @removed-ids*)]
531+
;; Tombstone the ids so the merge-on-write in update-workspaces-cache!
532+
;; does not resurrect them from a cache file shared with another server.
533+
(swap! db* update :deleted-chat-ids (fnil into #{}) removed-ids)
534+
(logger/info logger-tag (str "Cleaned up " (count removed-ids) " chat(s) older than " retention-days " days"))
453535
(update-workspaces-cache! @db* metrics)))))

src/eca/features/chat.clj

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2069,8 +2069,12 @@
20692069
{}
20702070
db
20712071
config)))
2072-
;; Delete chat from memory
2073-
(swap! db* update :chats dissoc chat-id)
2072+
;; Delete chat from memory; tombstone the id so the cache merge-on-write
2073+
;; never resurrects it from a cache file shared with another live server.
2074+
(swap! db* (fn [db]
2075+
(-> db
2076+
(update :chats dissoc chat-id)
2077+
(update :deleted-chat-ids (fnil conj #{}) chat-id))))
20742078
(messenger/chat-deleted messenger {:chat-id chat-id})
20752079
;; Save updated cache (without this chat)
20762080
(db/update-workspaces-cache! @db* metrics))))

0 commit comments

Comments
 (0)