1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
//! Remembers which thread belonged to which session, across daemon restarts.
//!
//! The agent keeps its own conversation history in the session state
//! directory, so resuming a thread needs only enough to find that directory
//! again. That is what is stored, and nothing else: no message content and no
//! credential.
//!
//! This is the daemon's only durable state, so it is written carefully. Losing
//! it does not stop the daemon starting, but it does silently withdraw every
//! thread anybody had open, which is the kind of failure that gets blamed on
//! the chat service.
use std::collections::BTreeMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::log::fields;
use crate::log::{LogValue, Logger};
/// Filename of the index inside the daemon's state directory.
pub const REGISTRY_FILENAME: &str = "threads.json";
/// How many threads are remembered before the oldest are dropped.
pub const MAX_REMEMBERED: usize = 500;
/// What is needed to put a thread back to work after a restart.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThreadRecord {
/// The chat thread the session runs in.
pub thread_id: String,
/// The session the thread belongs to.
pub session_id: String,
/// Where the agent's own session history lives.
pub state_dir: String,
/// The project the session works on.
pub project_name: String,
/// Where that project lives on the host.
pub project_path: String,
/// Who started the session, so ownership survives a restart.
pub owner_id: String,
/// Accounts the owner invited to take part.
///
/// Kept so a restart does not silently withdraw access somebody was given,
/// which would look like the bot ignoring them.
pub guests: Vec<String>,
/// The provider the session was started on, when it was chosen rather than
/// taken from the configuration.
///
/// Kept so a restart puts the thread back on the model it was working
/// with. Coming back on a different one is a change nobody asked for, and
/// a quiet one: the answers simply start reading differently.
pub provider: Option<String>,
/// The model the session was started on, when it was chosen.
pub model: Option<String>,
/// Milliseconds since the epoch, for evicting the least recently used.
pub updated_at: i64,
}
/// Whether a parsed entry is a record this daemon can resume.
pub fn is_record(value: &Value) -> bool {
let Some(record) = value.as_object() else {
return false;
};
let is_string = |key: &str| record.get(key).is_some_and(Value::is_string);
let is_optional_string =
|key: &str| matches!(record.get(key), None | Some(Value::Null | Value::String(_)));
is_string("threadId")
&& is_string("sessionId")
&& is_string("stateDir")
&& is_string("projectName")
&& is_string("projectPath")
&& is_string("ownerId")
&& record.get("guests").is_some_and(|guests| {
guests.is_array()
&& guests
.as_array()
.expect("checked above")
.iter()
.all(Value::is_string)
})
&& is_optional_string("provider")
&& is_optional_string("model")
&& record.get("updatedAt").is_some_and(Value::is_number)
}
/// A durable thread-to-session index.
pub struct ThreadRegistry {
path: String,
log: Logger,
records: BTreeMap<String, ThreadRecord>,
}
impl ThreadRegistry {
/// Opens the index kept at `path`, which [`path_for`](Self::path_for)
/// builds from a state directory.
pub fn new(path: String, log: Logger) -> Self {
Self {
path,
log,
records: BTreeMap::new(),
}
}
/// The index path inside a daemon state directory.
pub fn path_for(state_dir: &str) -> String {
Path::new(state_dir)
.join(REGISTRY_FILENAME)
.to_string_lossy()
.into_owned()
}
/// Reads the index.
///
/// A missing file is an empty index, which is the state on a first run. A
/// file that will not parse is kept aside rather than overwritten, because
/// it is the only copy of what was there and something has to be able to
/// look at it afterwards.
pub fn load(&mut self) {
self.records.clear();
let text = match std::fs::read_to_string(&self.path) {
Ok(text) => text,
Err(error) => {
if error.kind() != std::io::ErrorKind::NotFound {
self.log.error(
"the thread index could not be read, so no thread can be resumed",
&fields([
("path", self.path.as_str().into()),
("detail", error.to_string().into()),
]),
);
}
return;
}
};
let parsed: Result<Value, _> = serde_json::from_str(&text);
let Ok(parsed) = parsed else {
self.set_aside("the index is not JSON");
return;
};
let Some(entries) = parsed.as_array() else {
self.set_aside("the index is not a list of threads");
return;
};
let mut skipped = 0;
for entry in entries {
if is_record(entry) {
let record: ThreadRecord =
serde_json::from_value(entry.clone()).expect("checked by is_record");
self.records.insert(record.thread_id.clone(), record);
} else {
skipped += 1;
}
}
if skipped > 0 {
let skipped = i64::from(skipped);
self.log.warn(
"entries in the thread index were not readable and were dropped",
&fields([("skipped", LogValue::from(skipped))]),
);
}
}
/// The record for a thread, if one was ever kept.
pub fn get(&self, thread_id: &str) -> Option<&ThreadRecord> {
self.records.get(thread_id)
}
/// Number of threads currently remembered.
pub fn size(&self) -> usize {
self.records.len()
}
/// Every remembered thread, most recently used first.
pub fn all(&self) -> Vec<ThreadRecord> {
let mut all: Vec<ThreadRecord> = self.records.values().cloned().collect();
all.sort_by_key(|record| std::cmp::Reverse(record.updated_at));
all
}
/// Records a thread, evicting the least recently used past the bound.
pub fn remember(&mut self, record: ThreadRecord) {
self.records.insert(record.thread_id.clone(), record);
if self.records.len() > MAX_REMEMBERED {
let mut ordered: Vec<(String, i64)> = self
.records
.iter()
.map(|(id, record)| (id.clone(), record.updated_at))
.collect();
ordered.sort_by_key(|(_, updated_at)| *updated_at);
for (thread_id, _) in ordered
.into_iter()
.take(self.records.len() - MAX_REMEMBERED)
{
self.records.remove(&thread_id);
}
}
self.save();
}
/// Forgets a thread, so it is never resumed again.
pub fn forget(&mut self, thread_id: &str) {
if self.records.remove(thread_id).is_some() {
self.save();
}
}
/// Writes the index so that a crash leaves either the old one or the new
/// one.
///
/// Written beside the target, flushed, and renamed. The rename is what
/// makes it atomic; the flush before it is what stops a crash leaving a
/// file that was renamed into place before its contents reached the disk.
/// The directory is flushed afterwards so the rename itself survives.
fn save(&mut self) {
let temporary = format!("{}.tmp", self.path);
let records: Vec<&ThreadRecord> = self.records.values().collect();
let body = format!(
"{}\n",
serde_json::to_string_pretty(&records).unwrap_or_default()
);
let parent = Path::new(&self.path)
.parent()
.unwrap_or(Path::new("."))
.to_path_buf();
let written = std::fs::create_dir_all(&parent)
.and_then(|()| std::fs::write(&temporary, body))
.and_then(|()| {
let file = std::fs::OpenOptions::new().write(true).open(&temporary)?;
file.sync_all()
})
.and_then(|()| {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&temporary, std::fs::Permissions::from_mode(0o600))
}
#[cfg(not(unix))]
{
Ok(())
}
})
.and_then(|()| std::fs::rename(&temporary, &self.path));
if let Err(error) = written {
// Loud, because the cost is not visible until a restart, by which
// time the threads are gone and nothing says why.
self.log.error(
"the thread index could not be written, so a restart will forget threads",
&fields([
("path", self.path.as_str().into()),
("detail", error.to_string().into()),
]),
);
let _ = std::fs::remove_file(&temporary);
return;
}
Self::sync_directory(&parent);
}
/// Flushes the directory entry, so the rename survives a power loss.
///
/// Not every platform allows opening a directory, and not every file
/// system answers the flush. Either way the rename already happened; only
/// its durability is weaker, which is not worth failing the write over.
fn sync_directory(parent: &Path) {
let Ok(directory) = std::fs::File::open(parent) else {
return;
};
let _ = directory.sync_all();
}
/// Keeps an unreadable index rather than overwriting the only copy.
fn set_aside(&self, reason: &str) {
let kept = format!("{}.broken", self.path);
match std::fs::rename(&self.path, &kept) {
Ok(()) => self.log.error(
"the thread index was unreadable and was kept aside; no thread can resume",
&fields([("kept", kept.as_str().into()), ("detail", reason.into())]),
),
Err(error) => self.log.error(
"the thread index was unreadable and could not be kept aside",
&fields([
("path", self.path.as_str().into()),
("detail", error.to_string().into()),
]),
),
}
}
}
#[cfg(test)]
mod tests;