handoff-mcp-server 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,6 +8,48 @@ use std::path::{Path, PathBuf};
8
8
 
9
9
  use anyhow::{Context, Result};
10
10
 
11
+ /// Write `content` to `path` atomically: write to a sibling temp file, fsync,
12
+ /// then rename over the target. A rename within the same directory is atomic on
13
+ /// POSIX, so a concurrent reader never observes a partially-written file.
14
+ ///
15
+ /// Used by every handoff write path (tasks, config, sessions, referrals) so that
16
+ /// the VSCode extension — which writes the same files — never reads torn data.
17
+ pub fn atomic_write(path: impl AsRef<Path>, content: &[u8]) -> Result<()> {
18
+ use std::io::Write;
19
+
20
+ let path = path.as_ref();
21
+ let dir = path.parent().ok_or_else(|| {
22
+ anyhow::anyhow!("Cannot determine parent directory for {}", path.display())
23
+ })?;
24
+ let file_name = path
25
+ .file_name()
26
+ .and_then(|n| n.to_str())
27
+ .ok_or_else(|| anyhow::anyhow!("Invalid file name for {}", path.display()))?;
28
+
29
+ // Unique-per-process temp name in the same directory (so rename is atomic).
30
+ let tmp_name = format!(".{file_name}.tmp.{}", std::process::id());
31
+ let tmp_path = dir.join(tmp_name);
32
+
33
+ let mut f = std::fs::File::create(&tmp_path)
34
+ .with_context(|| format!("Failed to create temp file {}", tmp_path.display()))?;
35
+ f.write_all(content)
36
+ .with_context(|| format!("Failed to write temp file {}", tmp_path.display()))?;
37
+ f.sync_all()
38
+ .with_context(|| format!("Failed to sync temp file {}", tmp_path.display()))?;
39
+ drop(f);
40
+
41
+ std::fs::rename(&tmp_path, path).with_context(|| {
42
+ // Best-effort cleanup so a failed rename doesn't leave a stray temp file.
43
+ let _ = std::fs::remove_file(&tmp_path);
44
+ format!(
45
+ "Failed to rename {} -> {}",
46
+ tmp_path.display(),
47
+ path.display()
48
+ )
49
+ })?;
50
+ Ok(())
51
+ }
52
+
11
53
  pub fn expand_tilde(path: &str) -> String {
12
54
  if let Some(rest) = path.strip_prefix("~/") {
13
55
  if let Ok(home) = std::env::var("HOME") {
@@ -59,7 +59,7 @@ pub fn write_referral(referrals_dir: &Path, data: &ReferralData) -> Result<PathB
59
59
  let file_path = referrals_dir.join(&filename);
60
60
 
61
61
  let content = serde_json::to_string_pretty(data).context("Failed to serialize referral")?;
62
- std::fs::write(&file_path, content)
62
+ crate::storage::atomic_write(&file_path, content.as_bytes())
63
63
  .with_context(|| format!("Failed to write referral: {}", file_path.display()))?;
64
64
 
65
65
  Ok(file_path)
@@ -109,6 +109,10 @@ fn synthesize_id_from_filename(filename: &str) -> String {
109
109
  }
110
110
  }
111
111
 
112
+ fn ids_match(candidate: &str, query: &str) -> bool {
113
+ candidate == query || candidate.starts_with(query) || query.starts_with(candidate)
114
+ }
115
+
112
116
  pub fn write_open_session(sessions_dir: &Path, data: &SessionData) -> Result<PathBuf> {
113
117
  let mut data = data.clone();
114
118
  if data.id.is_none() {
@@ -121,7 +125,7 @@ pub fn write_open_session(sessions_dir: &Path, data: &SessionData) -> Result<Pat
121
125
  let path = sessions_dir.join(&filename);
122
126
 
123
127
  let content = serde_json::to_string_pretty(&data).context("Failed to serialize session")?;
124
- std::fs::write(&path, content)
128
+ crate::storage::atomic_write(&path, content.as_bytes())
125
129
  .with_context(|| format!("Failed to write session: {}", path.display()))?;
126
130
 
127
131
  Ok(path)
@@ -165,6 +169,40 @@ pub fn read_active_sessions(sessions_dir: &Path) -> Result<Vec<SessionData>> {
165
169
  read_sessions_by_status(sessions_dir, "active")
166
170
  }
167
171
 
172
+ /// Append `decision` (a decision object: {decision, reason?, confidence?}) to the
173
+ /// `decisions` list of every active session file. Returns the number of sessions
174
+ /// updated. Used by tools like auto_schedule to record applied changes so the
175
+ /// audit trail lives with the session, not just the tool response.
176
+ pub fn append_decision_to_active_sessions(
177
+ sessions_dir: &Path,
178
+ decision: serde_json::Value,
179
+ ) -> Result<usize> {
180
+ let suffix = ".active.json";
181
+ if !sessions_dir.exists() {
182
+ return Ok(0);
183
+ }
184
+
185
+ let mut count = 0;
186
+ for entry in std::fs::read_dir(sessions_dir)? {
187
+ let entry = entry?;
188
+ let name = entry.file_name().to_string_lossy().to_string();
189
+ if !name.ends_with(suffix) {
190
+ continue;
191
+ }
192
+ let path = entry.path();
193
+ let content = std::fs::read_to_string(&path)
194
+ .with_context(|| format!("Failed to read session: {}", path.display()))?;
195
+ let mut data: SessionData = serde_json::from_str(&content)
196
+ .with_context(|| format!("Failed to parse session: {}", path.display()))?;
197
+ data.decisions.push(decision.clone());
198
+ let updated = serde_json::to_string_pretty(&data).context("Failed to serialize session")?;
199
+ crate::storage::atomic_write(&path, updated.as_bytes())
200
+ .with_context(|| format!("Failed to write session: {}", path.display()))?;
201
+ count += 1;
202
+ }
203
+ Ok(count)
204
+ }
205
+
168
206
  fn transition_sessions(sessions_dir: &Path, from: &str, to: &str) -> Result<Vec<PathBuf>> {
169
207
  let mut transitioned = Vec::new();
170
208
  let from_suffix = format!(".{from}.json");
@@ -206,6 +244,8 @@ fn transition_session_by_id(
206
244
  return Ok(None);
207
245
  }
208
246
 
247
+ let mut matches: Vec<(PathBuf, String, String)> = Vec::new();
248
+
209
249
  for entry in std::fs::read_dir(sessions_dir)? {
210
250
  let entry = entry?;
211
251
  let name = entry.file_name().to_string_lossy().to_string();
@@ -219,25 +259,39 @@ fn transition_session_by_id(
219
259
  .with_context(|| format!("Failed to parse session: {}", entry.path().display()))?;
220
260
 
221
261
  let file_id = data.id.as_deref().unwrap_or("").to_string();
222
- let synthesized = if file_id.is_empty() {
262
+ let resolved_id = if file_id.is_empty() {
223
263
  synthesize_id_from_filename(&name)
224
264
  } else {
225
265
  file_id
226
266
  };
227
267
 
228
- if synthesized == session_id {
229
- let new_name = name.replace(&from_suffix, &to_suffix);
230
- let new_path = sessions_dir.join(&new_name);
231
- std::fs::rename(entry.path(), &new_path).with_context(|| {
232
- format!(
233
- "Failed to transition session {from}->{to}: {}",
234
- entry.path().display()
235
- )
236
- })?;
237
- return Ok(Some(new_path));
268
+ if ids_match(&resolved_id, session_id) {
269
+ matches.push((entry.path(), name, resolved_id));
238
270
  }
239
271
  }
240
272
 
273
+ if matches.len() > 1 {
274
+ let candidates: Vec<&str> = matches.iter().map(|(_, _, id)| id.as_str()).collect();
275
+ anyhow::bail!(
276
+ "Ambiguous session_id '{}': matched {} sessions ({}). Provide a more specific ID.",
277
+ session_id,
278
+ matches.len(),
279
+ candidates.join(", ")
280
+ );
281
+ }
282
+
283
+ if let Some((path, name, _)) = matches.into_iter().next() {
284
+ let new_name = name.replace(&from_suffix, &to_suffix);
285
+ let new_path = sessions_dir.join(&new_name);
286
+ std::fs::rename(&path, &new_path).with_context(|| {
287
+ format!(
288
+ "Failed to transition session {from}->{to}: {}",
289
+ path.display()
290
+ )
291
+ })?;
292
+ return Ok(Some(new_path));
293
+ }
294
+
241
295
  Ok(None)
242
296
  }
243
297
 
@@ -329,6 +383,8 @@ fn find_and_update_active_session(
329
383
  return Ok(None);
330
384
  }
331
385
 
386
+ let mut matches: Vec<(PathBuf, String, String)> = Vec::new();
387
+
332
388
  for entry in std::fs::read_dir(sessions_dir)? {
333
389
  let entry = entry?;
334
390
  let name = entry.file_name().to_string_lossy().to_string();
@@ -338,41 +394,58 @@ fn find_and_update_active_session(
338
394
 
339
395
  let content = std::fs::read_to_string(entry.path())
340
396
  .with_context(|| format!("Failed to read session: {}", entry.path().display()))?;
341
- let mut data: SessionData = serde_json::from_str(&content)
397
+ let data: SessionData = serde_json::from_str(&content)
342
398
  .with_context(|| format!("Failed to parse session: {}", entry.path().display()))?;
343
399
 
344
400
  let file_id = data.id.as_deref().unwrap_or("").to_string();
345
- let synthesized = if file_id.is_empty() {
401
+ let resolved_id = if file_id.is_empty() {
346
402
  synthesize_id_from_filename(&name)
347
403
  } else {
348
404
  file_id
349
405
  };
350
406
 
351
- if synthesized != session_id {
352
- continue;
407
+ if ids_match(&resolved_id, session_id) {
408
+ matches.push((entry.path(), name, resolved_id));
353
409
  }
410
+ }
411
+
412
+ if matches.len() > 1 {
413
+ let candidates: Vec<&str> = matches.iter().map(|(_, _, id)| id.as_str()).collect();
414
+ anyhow::bail!(
415
+ "Ambiguous session_id '{}': matched {} sessions ({}). Provide a more specific ID.",
416
+ session_id,
417
+ matches.len(),
418
+ candidates.join(", ")
419
+ );
420
+ }
421
+
422
+ if let Some((path, name, _)) = matches.into_iter().next() {
423
+ let content = std::fs::read_to_string(&path)
424
+ .with_context(|| format!("Failed to read session: {}", path.display()))?;
425
+ let mut data: SessionData = serde_json::from_str(&content)
426
+ .with_context(|| format!("Failed to parse session: {}", path.display()))?;
354
427
 
355
428
  apply_session_updates(&mut data, updates);
356
429
 
357
430
  let updated_content =
358
431
  serde_json::to_string_pretty(&data).context("Failed to serialize session")?;
359
- std::fs::write(entry.path(), &updated_content)
360
- .with_context(|| format!("Failed to write session: {}", entry.path().display()))?;
432
+ crate::storage::atomic_write(&path, updated_content.as_bytes())
433
+ .with_context(|| format!("Failed to write session: {}", path.display()))?;
361
434
 
362
435
  if let Some(target_status) = transition_to {
363
436
  let target_suffix = format!(".{target_status}.json");
364
437
  let new_name = name.replace(suffix, &target_suffix);
365
438
  let new_path = sessions_dir.join(&new_name);
366
- std::fs::rename(entry.path(), &new_path).with_context(|| {
439
+ std::fs::rename(&path, &new_path).with_context(|| {
367
440
  format!(
368
441
  "Failed to transition session active->{target_status}: {}",
369
- entry.path().display()
442
+ path.display()
370
443
  )
371
444
  })?;
372
445
  return Ok(Some(new_path));
373
446
  }
374
447
 
375
- return Ok(Some(entry.path()));
448
+ return Ok(Some(path));
376
449
  }
377
450
 
378
451
  Ok(None)
@@ -440,7 +513,7 @@ pub fn write_session_with_status(
440
513
  let path = sessions_dir.join(&filename);
441
514
 
442
515
  let content = serde_json::to_string_pretty(&data).context("Failed to serialize session")?;
443
- std::fs::write(&path, content)
516
+ crate::storage::atomic_write(&path, content.as_bytes())
444
517
  .with_context(|| format!("Failed to write session: {}", path.display()))?;
445
518
 
446
519
  Ok(path)
@@ -4,6 +4,11 @@ use std::path::{Path, PathBuf};
4
4
  use anyhow::{Context, Result};
5
5
  use chrono::Utc;
6
6
  use serde::{Deserialize, Serialize};
7
+ use serde_json::Value;
8
+
9
+ fn is_empty_map(m: &HashMap<String, Value>) -> bool {
10
+ m.is_empty()
11
+ }
7
12
 
8
13
  #[derive(Debug, Clone, Serialize, Deserialize)]
9
14
  pub struct TaskData {
@@ -31,9 +36,13 @@ pub struct TaskData {
31
36
  pub dependencies: Vec<String>,
32
37
  #[serde(default, skip_serializing_if = "Option::is_none")]
33
38
  pub order: Option<u32>,
39
+ #[serde(default, skip_serializing_if = "Option::is_none")]
40
+ pub assignee: Option<String>,
41
+ #[serde(flatten, default, skip_serializing_if = "is_empty_map")]
42
+ pub extra: HashMap<String, Value>,
34
43
  }
35
44
 
36
- #[derive(Debug, Clone, Serialize, Deserialize)]
45
+ #[derive(Debug, Clone, Default, Serialize, Deserialize)]
37
46
  pub struct Schedule {
38
47
  #[serde(default, skip_serializing_if = "Option::is_none")]
39
48
  pub start_date: Option<String>,
@@ -44,7 +53,11 @@ pub struct Schedule {
44
53
  #[serde(default, skip_serializing_if = "Option::is_none")]
45
54
  pub actual_hours: Option<f64>,
46
55
  #[serde(default, skip_serializing_if = "Option::is_none")]
56
+ pub remaining_hours: Option<f64>,
57
+ #[serde(default, skip_serializing_if = "Option::is_none")]
47
58
  pub milestone: Option<String>,
59
+ #[serde(default, skip_serializing_if = "Option::is_none")]
60
+ pub pinned: Option<bool>,
48
61
  }
49
62
 
50
63
  #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -65,6 +78,8 @@ pub struct TaskIndex {
65
78
  pub dependencies: Vec<String>,
66
79
  #[serde(default, skip_serializing_if = "Option::is_none")]
67
80
  pub order: Option<u32>,
81
+ #[serde(default, skip_serializing_if = "Option::is_none")]
82
+ pub assignee: Option<String>,
68
83
  #[serde(default, skip_serializing_if = "Vec::is_empty")]
69
84
  pub children: Vec<TaskIndex>,
70
85
  }
@@ -190,11 +205,60 @@ pub fn read_task(task_dir: &Path) -> Result<Option<(TaskData, String)>> {
190
205
  pub fn write_task(task_dir: &Path, status: &str, data: &TaskData) -> Result<()> {
191
206
  let file_path = task_dir.join(format!("_task.{status}.json"));
192
207
  let content = serde_json::to_string_pretty(data).context("Failed to serialize task")?;
193
- std::fs::write(&file_path, content)
208
+ crate::storage::atomic_write(&file_path, content.as_bytes())
194
209
  .with_context(|| format!("Failed to write task: {}", file_path.display()))?;
195
210
  Ok(())
196
211
  }
197
212
 
213
+ /// Read-modify-write a task with optimistic concurrency control.
214
+ ///
215
+ /// Reads the current task, runs `mutate` on a copy, then re-reads just before
216
+ /// writing: if the file's `updated_at` changed since the snapshot (another
217
+ /// writer — e.g. the VSCode extension — won the race), the whole cycle retries
218
+ /// up to `MAX_RETRIES` times. This matches the VSCode side's `updated_at`
219
+ /// protocol (wiki/95-concurrency-safety.md) and prevents lost updates that
220
+ /// atomic_write alone cannot (atomic_write stops *torn* reads, not *lost*
221
+ /// updates).
222
+ ///
223
+ /// `mutate` receives the current `TaskData` and the resolved status, and returns
224
+ /// the new status the task should have after the change (usually unchanged).
225
+ pub fn read_modify_write_task<F>(task_dir: &Path, mut mutate: F) -> Result<()>
226
+ where
227
+ F: FnMut(&mut TaskData, &str) -> Result<String>,
228
+ {
229
+ const MAX_RETRIES: usize = 5;
230
+
231
+ for attempt in 0..=MAX_RETRIES {
232
+ let (mut data, status) = read_task(task_dir)?
233
+ .ok_or_else(|| anyhow::anyhow!("Task file not found in {}", task_dir.display()))?;
234
+ let snapshot_updated_at = data.updated_at.clone();
235
+
236
+ let new_status = mutate(&mut data, &status)?;
237
+
238
+ // Re-read to detect a concurrent writer before committing.
239
+ let current_updated_at = read_task(task_dir)?.and_then(|(d, _)| d.updated_at);
240
+ if current_updated_at != snapshot_updated_at {
241
+ // Someone else wrote between our read and write. Retry from scratch.
242
+ if attempt == MAX_RETRIES {
243
+ anyhow::bail!(
244
+ "Concurrent modification of task in {} after {} retries; aborting to avoid \
245
+ overwriting another writer's changes.",
246
+ task_dir.display(),
247
+ MAX_RETRIES
248
+ );
249
+ }
250
+ continue;
251
+ }
252
+
253
+ if new_status != status {
254
+ change_status(task_dir, &new_status)?;
255
+ }
256
+ write_task(task_dir, &new_status, &data)?;
257
+ return Ok(());
258
+ }
259
+ unreachable!("loop returns or bails within MAX_RETRIES iterations")
260
+ }
261
+
198
262
  pub fn change_status(task_dir: &Path, new_status: &str) -> Result<()> {
199
263
  if !is_valid_status(new_status) {
200
264
  anyhow::bail!("Invalid status: {new_status}");
@@ -423,6 +487,7 @@ fn build_index_recursive(
423
487
  schedule: data.schedule,
424
488
  dependencies: data.dependencies,
425
489
  order: data.order,
490
+ assignee: data.assignee,
426
491
  children,
427
492
  });
428
493
  }