Skip to content

Commit 1024a67

Browse files
committed
bugfix: memory migration crash
1 parent 68c2134 commit 1024a67

2 files changed

Lines changed: 90 additions & 11 deletions

File tree

lib/store/project/conversation/task_list_status_migration.ex

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,26 @@
11
defmodule Store.Project.Conversation.TaskListStatusMigration do
22
@moduledoc """
3-
Helpers to heal and migrate conversation task-list statuses and shapes.
3+
Helpers to best-effort heal and migrate conversation task-list statuses and shapes.
44
55
These functions are intentionally minimal and safe: they only alter the
66
`tasks` map of the decoded conversation JSON if legacy shapes are detected
77
(for example the value is a bare list instead of a map), or if a `status`
8-
field is missing or non-canonical. The migration is performed in-place via
9-
an atomic write that preserves the original timestamp prefix used by the
10-
Store.Project.Conversation format.
8+
field is missing or non-canonical. Repairs are applied in-memory, and the
9+
module attempts an atomic write of the repaired JSON back to disk, but if
10+
persistence fails, processing continues without raising. The timestamp prefix
11+
used by the Store.Project.Conversation format is preserved.
1112
"""
1213

1314
@doc """
1415
Inspect the decoded JSON map `original_json_map` for legacy task-list
15-
shapes and/or non-canonical status values. If repairs are needed, atomically
16-
write the repaired JSON back to disk using the provided `conversation` and
17-
the original `timestamp_str` (which is the unix timestamp string prefix).
16+
shapes and/or non-canonical status values. If repairs are needed, apply them
17+
in-memory and attempt to atomically persist the repaired JSON to disk using
18+
the provided `conversation` and the original `timestamp_str` (the unix
19+
timestamp string prefix).
20+
21+
Persistence is best-effort: if the write fails (for example due to concurrent
22+
access or filesystem issues), this function will warn and continue without
23+
raising so callers can keep reading the conversation.
1824
1925
This function is safe to call from Store.Project.Conversation.read/1; it is
2026
conservative and only writes when it detects a change.
@@ -56,7 +62,17 @@ defmodule Store.Project.Conversation.TaskListStatusMigration do
5662
end)
5763

5864
if changed do
59-
write_file_with_ts(conversation, timestamp_str, repaired)
65+
case write_file_with_ts(conversation, timestamp_str, repaired) do
66+
:ok ->
67+
:ok
68+
69+
{:error, reason} ->
70+
UI.warn(
71+
"Could not persist healed tasks for conversation #{conversation.id}: #{inspect(reason)}"
72+
)
73+
74+
:ok
75+
end
6076
else
6177
:ok
6278
end
@@ -82,14 +98,41 @@ defmodule Store.Project.Conversation.TaskListStatusMigration do
8298
end
8399
end
84100

101+
@spec write_file_with_ts(Store.Project.Conversation.t(), String.t(), map()) ::
102+
:ok | {:error, term()}
85103
defp write_file_with_ts(conversation, timestamp_str, data_map) do
86104
conversation.project_home
87105
|> Path.join("conversations")
88106
|> File.mkdir_p()
89107

90-
json = SafeJson.encode!(data_map)
91108
tmp = conversation.store_path <> ".tmp"
92-
File.write!(tmp, "#{timestamp_str}:" <> json)
93-
File.rename!(tmp, conversation.store_path)
109+
110+
with {:ok, json} <- SafeJson.encode(data_map),
111+
:ok <- File.write(tmp, "#{timestamp_str}:" <> json),
112+
:ok <- safe_rename(tmp, conversation.store_path) do
113+
:ok
114+
else
115+
{:error, reason} ->
116+
File.rm(tmp)
117+
{:error, reason}
118+
end
119+
end
120+
121+
@spec safe_rename(String.t(), String.t()) :: :ok | {:error, term()}
122+
defp safe_rename(tmp, dest) do
123+
case File.rename(tmp, dest) do
124+
:ok ->
125+
:ok
126+
127+
{:error, :enoent} ->
128+
if File.exists?(tmp) do
129+
{:error, :dest_missing}
130+
else
131+
{:error, :tmp_missing}
132+
end
133+
134+
{:error, reason} ->
135+
{:error, reason}
136+
end
94137
end
95138
end

test/store/project/conversation_test.exs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,42 @@ defmodule Store.Project.ConversationTest do
163163
assert [%{id: "a1", data: "info", outcome: :done, result: "ok"}] = task_list
164164
end
165165

166+
test "read/1 does not crash when migration write fails", ctx do
167+
# Prepare a legacy JSON file to trigger migration
168+
id = "mig_fail"
169+
convo = Conversation.new(id, ctx.project)
170+
File.mkdir_p!(Path.dirname(convo.store_path))
171+
172+
legacy_tasks = [
173+
%{"id" => "t1", "data" => "d1", "outcome" => "todo", "result" => nil}
174+
]
175+
176+
legacy_data = %{
177+
"messages" => [],
178+
"metadata" => %{},
179+
"memory" => [],
180+
"tasks" => %{"123" => legacy_tasks}
181+
}
182+
183+
timestamp = 99
184+
File.write!(convo.store_path, "#{timestamp}:" <> SafeJson.encode!(legacy_data))
185+
186+
# Create a directory at .tmp to cause migration write to fail
187+
tmp_path = convo.store_path <> ".tmp"
188+
File.mkdir_p!(tmp_path)
189+
190+
before_contents = File.read!(convo.store_path)
191+
# Ensure read/1 still succeeds
192+
assert {:ok, %{tasks: tasks_map}} = Conversation.read(convo)
193+
194+
assert Map.has_key?(tasks_map, "123")
195+
assert %{tasks: tasks_list, description: nil} = tasks_map["123"]
196+
assert [%{id: "t1", data: "d1", outcome: :todo, result: nil}] = tasks_list
197+
198+
# File contents remain unchanged
199+
assert File.read!(convo.store_path) == before_contents
200+
end
201+
166202
test "list/1 returns conversations in descending timestamp order", ctx do
167203
id1 = "one"
168204
id2 = "two"

0 commit comments

Comments
 (0)