Skip to content

Commit 8b311a9

Browse files
committed
Fix chats randomly disappearing from /resume after restart
The per-workspace chat cache dir was named <prefix>_<hash> with the prefix taken from the first workspace folder, so folder order/parent differences resolved the same workspace to different dirs and split chats across them. Key the dir by the order-independent hash, keep the prefix cosmetic, and merge any fragmented dirs into the canonical one on load (newest chat wins), recovering already-stranded chats. eca-intellij #20
1 parent ee006bf commit 8b311a9

5 files changed

Lines changed: 155 additions & 72 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## Unreleased
44

55
- New `eca read-chat` CLI command for streaming raw chat DB cache records as JSONL.
6+
- Fix chats randomly disappearing from `/resume` after restart: key the workspace chat cache on the order-independent workspace hash and merge caches fragmented across differently-named dirs. (eca-intellij #20)
67

78
## 0.136.0
89

‎src/eca/cache.clj‎

Lines changed: 45 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,20 @@
1717
(io/file (System/getProperty "user.home") ".cache"))]
1818
(io/file cache-home "eca")))
1919

20+
(defn ^:private sorted-workspace-paths
21+
"Absolute workspace paths, de-duplicated and sorted, so the result is stable
22+
regardless of the order the editor reports its workspace folders."
23+
[workspaces uri->filename-fn]
24+
(->> workspaces
25+
(map #(str (fs/absolutize (fs/file (uri->filename-fn (:uri %))))))
26+
(distinct)
27+
(sort)))
28+
2029
(defn workspaces-hash
21-
"Returns an 8-char base64 (URL-safe, no padding) hash key for the given workspace set."
30+
"Returns an 8-char base64 (URL-safe, no padding) hash key for the given workspace set.
31+
Order-independent: the same set of folders always yields the same hash."
2232
[workspaces uri->filename-fn]
23-
(let [paths (->> workspaces
24-
(map #(str (fs/absolutize (fs/file (uri->filename-fn (:uri %))))))
25-
(distinct)
26-
(sort))
27-
joined (string/join ":" paths)
33+
(let [joined (string/join ":" (sorted-workspace-paths workspaces uri->filename-fn))
2834
md (java.security.MessageDigest/getInstance "SHA-256")
2935
digest (.digest (doto md (.update (.getBytes joined "UTF-8"))))
3036
encoder (-> (java.util.Base64/getUrlEncoder)
@@ -38,44 +44,50 @@
3844

3945
(defn ^:private workspace-dir-name
4046
"Returns a human-readable directory name for the workspace cache.
41-
Format: <sanitized-project-name>_<hash>, or just <hash> if no name is available."
47+
Format: <sanitized-project-name>_<hash>, or just <hash> if no name is available.
48+
The prefix comes from the sorted-first workspace path so it stays stable
49+
regardless of folder order; it is purely cosmetic - the <hash> is the identity."
4250
[workspaces uri->filename-fn]
4351
(let [hash (workspaces-hash workspaces uri->filename-fn)
44-
first-uri (some-> workspaces first :uri)
45-
project-name (when first-uri
46-
(some-> (uri->filename-fn first-uri)
47-
fs/file-name
48-
str
49-
not-empty))
52+
project-name (some-> (first (sorted-workspace-paths workspaces uri->filename-fn))
53+
fs/file-name
54+
str
55+
not-empty)
5056
sanitized (when project-name
5157
(let [s (string/replace project-name #"[^a-zA-Z0-9._-]" "_")]
5258
(subs s 0 (min max-prefix-length (count s)))))]
5359
(if (not-empty sanitized)
5460
(str sanitized "_" hash)
5561
hash)))
5662

57-
(defn ^:private migrate-workspace-cache-dir!
58-
"Migrates old hash-only workspace cache directory to new human-readable format."
59-
[^File old-dir ^File new-dir]
60-
(try
61-
(fs/move old-dir new-dir)
62-
(logger/info logger-tag (str "Migrated workspace cache from " old-dir " to " new-dir))
63-
(catch Exception e
64-
(logger/warn logger-tag "Failed to migrate workspace cache directory:" (.getMessage e)))))
65-
6663
(defn workspace-cache-file
67-
"Returns a File object for a workspace-specific cache file."
64+
"Returns a File object for a workspace-specific cache file.
65+
The directory identity is the order-independent <hash>; the human-readable
66+
prefix is cosmetic. Healing of caches fragmented across differently-named
67+
dirs for the same workspace is handled by eca.db/consolidate-workspace-cache!."
6868
[workspaces filename uri->filename-fn]
69-
(let [dir-name (workspace-dir-name workspaces uri->filename-fn)
70-
hash-only (workspaces-hash workspaces uri->filename-fn)
71-
base (global-dir)
72-
new-dir (io/file base dir-name)
73-
old-dir (io/file base hash-only)]
74-
(when (and (not= dir-name hash-only)
75-
(not (fs/exists? new-dir))
76-
(fs/exists? old-dir))
77-
(migrate-workspace-cache-dir! old-dir new-dir))
78-
(io/file new-dir filename)))
69+
(io/file (global-dir) (workspace-dir-name workspaces uri->filename-fn) filename))
70+
71+
(defn redundant-workspace-cache-files
72+
"Returns the cache files named `filename` that live in directories belonging to
73+
the same workspace set as the canonical dir but under a different name - i.e.
74+
legacy hash-only dirs, or dirs prefixed from a different folder order. The
75+
canonical dir is excluded. Used to heal fragmented chat caches."
76+
[workspaces filename uri->filename-fn]
77+
(let [hash (workspaces-hash workspaces uri->filename-fn)
78+
canonical-dir-name (workspace-dir-name workspaces uri->filename-fn)
79+
base (global-dir)]
80+
(if (fs/exists? base)
81+
(->> (fs/list-dir base)
82+
(filter fs/directory?)
83+
(map #(str (fs/file-name %)))
84+
(filter (fn [n] (or (= n hash)
85+
(string/ends-with? n (str "_" hash)))))
86+
(remove #(= % canonical-dir-name))
87+
(map #(io/file base % filename))
88+
(filter fs/exists?)
89+
(vec))
90+
[])))
7991

8092
(def ^:private tool-call-outputs-dir-name "toolCallOutputs")
8193
(def ^:private plugins-dir-name "plugins")

‎src/eca/db.clj‎

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -264,14 +264,58 @@
264264
(when (= version (:version cache))
265265
cache)))
266266

267+
(defn ^:private chat-recency [chat]
268+
(or (:updated-at chat) (:created-at chat) 0))
269+
270+
(defn ^:private merge-chats
271+
"Merges chat maps into one. On a duplicate chat id, keeps the entry with the
272+
greater recency (`:updated-at`, falling back to `:created-at`), so a newer
273+
chat is never clobbered by a staler copy living in another cache dir."
274+
[chat-maps]
275+
(reduce (fn [acc chats]
276+
(reduce-kv (fn [m id chat]
277+
(if-let [existing (get m id)]
278+
(if (> (chat-recency chat) (chat-recency existing))
279+
(assoc m id chat)
280+
m)
281+
(assoc m id chat)))
282+
acc
283+
chats))
284+
{}
285+
chat-maps))
286+
287+
(defn consolidate-workspace-cache!
288+
"Heals chat caches that fragmented across multiple directories for the same
289+
workspace set (legacy hash-only dirs, or dirs prefixed from a different folder
290+
order). Merges every matching cache into the canonical dir (newest chat wins)
291+
and removes the redundant dirs. Best-effort and idempotent."
292+
[workspaces metrics]
293+
(try
294+
(let [redundant (cache/redundant-workspace-cache-files workspaces "db.transit.json" shared/uri->filename)]
295+
(when (seq redundant)
296+
(let [canonical (transit-global-by-workspaces-db-file workspaces)
297+
caches (keep #(read-cache % metrics) (cons canonical redundant))
298+
merged (merge-chats (map :chats caches))]
299+
(logger/info logger-tag (str "Consolidating " (count redundant) " redundant workspace cache dir(s) into " canonical))
300+
(upsert-cache! {:chats merged :version version} canonical metrics)
301+
(doseq [^java.io.File f redundant]
302+
(try
303+
(fs/delete-tree (.getParentFile f))
304+
(catch Throwable e
305+
(logger/warn logger-tag (str "Could not remove redundant cache dir " (.getParentFile f)) e)))))))
306+
(catch Throwable e
307+
(logger/warn logger-tag "Could not consolidate workspace cache" e))))
308+
267309
(defn load-db-from-cache! [db* config metrics]
268310
(when-not (:pureConfig config)
269311
(when-let [global-cache (read-global-cache metrics)]
270312
(logger/info logger-tag "Loading from global-cache caches...")
271313
(swap! db* shared/deep-merge global-cache))
272-
(when-let [global-by-workspace-cache (read-global-by-workspaces-cache (:workspace-folders @db*) metrics)]
273-
(logger/info logger-tag "Loading from workspace-cache caches...")
274-
(swap! db* shared/deep-merge global-by-workspace-cache))))
314+
(let [workspaces (:workspace-folders @db*)]
315+
(consolidate-workspace-cache! workspaces metrics)
316+
(when-let [global-by-workspace-cache (read-global-by-workspaces-cache workspaces metrics)]
317+
(logger/info logger-tag "Loading from workspace-cache caches...")
318+
(swap! db* shared/deep-merge global-by-workspace-cache)))))
275319

276320
(defn ^:private normalize-db-for-workspace-write [db]
277321
(-> (select-keys db [:chats])

‎test/eca/cache_test.clj‎

Lines changed: 37 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -83,41 +83,43 @@
8383
(let [result (dir-name [] identity)]
8484
(is (re-matches #".{8}" result))))
8585

86-
(testing "uses first workspace name when multiple workspaces"
87-
(let [workspaces [{:uri "/home/user/first-project"}
88-
{:uri "/home/user/second-project"}]
89-
result (dir-name workspaces identity)]
90-
(is (re-matches #"first-project_.{8}" result))))))
91-
92-
(deftest workspace-cache-file-migration-test
93-
(testing "migrates old hash-only directory to new format"
86+
(testing "uses sorted-first workspace name regardless of folder order"
87+
(let [a {:uri "/home/user/first-project"}
88+
b {:uri "/home/user/second-project"}]
89+
(is (re-matches #"first-project_.{8}" (dir-name [a b] identity)))
90+
(is (= (dir-name [a b] identity) (dir-name [b a] identity)))))))
91+
92+
(deftest workspaces-hash-order-independent-test
93+
(testing "the same set of folders hashes the same regardless of order"
94+
(let [a {:uri "/home/user/aaa"}
95+
b {:uri "/home/user/bbb"}]
96+
(is (= (cache/workspaces-hash [a b] identity)
97+
(cache/workspaces-hash [b a] identity))))))
98+
99+
(deftest workspace-cache-file-stable-test
100+
(testing "resolves to the same canonical file regardless of folder order"
94101
(with-temp-cache-dir
95-
(let [workspaces [{:uri "/home/user/my-project"}]
96-
hash-only (cache/workspaces-hash workspaces identity)
97-
old-dir (io/file (cache/global-dir) hash-only)]
98-
;; Create old-format directory with a cache file
99-
(fs/create-dirs old-dir)
100-
(spit (io/file old-dir "db.transit.json") "{}")
101-
102-
(let [result (cache/workspace-cache-file workspaces "db.transit.json" identity)]
103-
(is (not (fs/exists? old-dir)) "Old directory should be renamed")
104-
(is (fs/exists? (.getParentFile result)) "New directory should exist")
105-
(is (= "{}" (slurp result)) "Migrated file content should be preserved")
106-
(is (re-find #"my-project_" (str result)) "New path should contain project name")))))
107-
108-
(testing "does not migrate when new directory already exists"
102+
(let [a {:uri "/home/user/aaa"}
103+
b {:uri "/home/user/bbb"}]
104+
(is (= (str (cache/workspace-cache-file [a b] "db.transit.json" identity))
105+
(str (cache/workspace-cache-file [b a] "db.transit.json" identity))))))))
106+
107+
(deftest redundant-workspace-cache-files-test
108+
(testing "finds legacy hash-only and differently-prefixed dirs for the same workspace, excluding canonical"
109109
(with-temp-cache-dir
110110
(let [workspaces [{:uri "/home/user/my-project"}]
111-
hash-only (cache/workspaces-hash workspaces identity)
112-
old-dir (io/file (cache/global-dir) hash-only)
113-
result-before (cache/workspace-cache-file workspaces "db.transit.json" identity)
114-
new-dir (.getParentFile result-before)]
115-
;; Create both directories
116-
(fs/create-dirs old-dir)
117-
(spit (io/file old-dir "db.transit.json") "old")
118-
(fs/create-dirs new-dir)
119-
(spit (io/file new-dir "db.transit.json") "new")
120-
121-
(let [result (cache/workspace-cache-file workspaces "db.transit.json" identity)]
122-
(is (fs/exists? old-dir) "Old directory should remain untouched")
123-
(is (= "new" (slurp result)) "Should use new directory content"))))))
111+
ws-hash (cache/workspaces-hash workspaces identity)
112+
base (cache/global-dir)
113+
canonical (cache/workspace-cache-file workspaces "db.transit.json" identity)
114+
hash-only-file (io/file base ws-hash "db.transit.json")
115+
other-prefixed-file (io/file base (str "old_" ws-hash) "db.transit.json")]
116+
(fs/create-dirs (.getParentFile canonical))
117+
(spit canonical "canonical")
118+
(fs/create-dirs (.getParentFile hash-only-file))
119+
(spit hash-only-file "legacy")
120+
(fs/create-dirs (.getParentFile other-prefixed-file))
121+
(spit other-prefixed-file "other")
122+
(let [redundant (set (map str (cache/redundant-workspace-cache-files workspaces "db.transit.json" identity)))]
123+
(is (contains? redundant (str hash-only-file)))
124+
(is (contains? redundant (str other-prefixed-file)))
125+
(is (not (contains? redundant (str canonical)))))))))

‎test/eca/db_test.clj‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
[clojure.test :refer [deftest is testing]]
66
[cognitect.transit :as transit]
77
[eca.cache :as cache]
8-
[eca.db :as db])
8+
[eca.db :as db]
9+
[eca.shared :as shared])
910
(:import
1011
[java.io File]))
1112

@@ -231,3 +232,26 @@
231232
(is (contains? (:chats result) "no-msgs-key")))
232233
(testing ":tool-calls runtime state is stripped before persisting"
233234
(is (not (contains? (get-in result [:chats "with-msg"]) :tool-calls))))))
235+
236+
(deftest consolidate-workspace-cache!-merges-and-removes-redundant-dirs-test
237+
(testing "merges chats from a hash-only dir into the canonical dir (newest wins) and removes the redundant dir"
238+
(let [tmpdir (str (fs/create-temp-dir))]
239+
(with-redefs [cache/global-dir (constantly (io/file tmpdir))]
240+
(try
241+
(let [workspaces [{:uri "file:///home/user/projX"}]
242+
canonical (cache/workspace-cache-file workspaces "db.transit.json" shared/uri->filename)
243+
ws-hash (cache/workspaces-hash workspaces shared/uri->filename)
244+
hash-only-dir (io/file (cache/global-dir) ws-hash)
245+
hash-only-file (io/file hash-only-dir "db.transit.json")
246+
upsert! @#'db/upsert-cache!]
247+
;; canonical holds an older copy of chat "a"
248+
(upsert! {:version db/version :chats {"a" {:id "a" :updated-at 100 :title "old-a"}}} canonical nil)
249+
;; a legacy hash-only dir holds a newer "a" plus an extra chat "b"
250+
(upsert! {:version db/version :chats {"a" {:id "a" :updated-at 200 :title "new-a"}
251+
"b" {:id "b" :updated-at 50 :title "b"}}} hash-only-file nil)
252+
(db/consolidate-workspace-cache! workspaces nil)
253+
(let [merged (:chats (read-transit-file canonical))]
254+
(is (= #{"a" "b"} (set (keys merged))) "all chats end up in the canonical dir")
255+
(is (= "new-a" (get-in merged ["a" :title])) "newest :updated-at wins on conflict")
256+
(is (not (fs/exists? hash-only-dir)) "redundant dir is removed")))
257+
(finally (fs/delete-tree tmpdir)))))))

0 commit comments

Comments
 (0)