1use std::{
10 fs::{self, OpenOptions},
11 io,
12 path::Path,
13 sync::atomic::{AtomicU64, Ordering},
14};
15
16use fs2::FileExt;
17use objects::object::StateId;
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20
21use crate::harness_json::{first_value_string, value_string};
22
23pub const IDENTITY_CURSOR_FILE: &str = "identity";
25const LAST_TURN_ANCHOR_FILE: &str = "last-turn";
26
27#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
29pub struct LastTurnAnchor {
30 pub session_id: String,
31 pub state_id: StateId,
32}
33
34#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
36pub struct IdentityCursor {
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub provider: Option<String>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub model: Option<String>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub thought_level: Option<String>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub session: Option<String>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub parent: Option<String>,
47}
48
49impl IdentityCursor {
50 pub fn omit_unpublished(mut self) -> Self {
52 self.provider = published_owned(self.provider);
53 self.model = published_owned(self.model);
54 self.thought_level = published_owned(self.thought_level);
55 self.session = published_owned(self.session);
56 self.parent = published_owned(self.parent);
57 self
58 }
59
60 pub fn merge_event(&self, patch: &IdentityCursor) -> Self {
68 Self {
69 provider: published_owned(patch.provider.clone()).or_else(|| self.provider.clone()),
70 model: published_owned(patch.model.clone()).or_else(|| self.model.clone()),
71 thought_level: published_owned(patch.thought_level.clone())
72 .or_else(|| self.thought_level.clone()),
73 session: published_owned(patch.session.clone()).or_else(|| self.session.clone()),
74 parent: merge_parent(self, patch),
75 }
76 .omit_unpublished()
77 }
78
79 pub fn is_empty(&self) -> bool {
80 self.provider.is_none()
81 && self.model.is_none()
82 && self.thought_level.is_none()
83 && self.session.is_none()
84 && self.parent.is_none()
85 }
86
87 pub fn to_vec(&self) -> Result<Vec<u8>, serde_json::Error> {
89 serde_json::to_vec(self)
90 }
91}
92
93pub fn published_field(value: Option<&str>) -> Option<&str> {
95 value
96 .map(str::trim)
97 .filter(|value| !value.is_empty() && !value.eq_ignore_ascii_case("unknown"))
98}
99
100fn published_owned(value: Option<String>) -> Option<String> {
101 published_field(value.as_deref()).map(str::to_string)
102}
103
104fn merge_parent(current: &IdentityCursor, patch: &IdentityCursor) -> Option<String> {
105 let incoming_parent = published_owned(patch.parent.clone());
106 let incoming_session = published_owned(patch.session.clone());
107 if incoming_parent
108 .as_ref()
109 .zip(incoming_session.as_ref())
110 .is_some_and(|(parent, session)| parent == session)
111 {
112 return None;
113 }
114 if let Some(incoming) = incoming_parent {
115 return Some(incoming);
116 }
117 if incoming_session
118 .as_ref()
119 .zip(current.parent.as_ref())
120 .is_some_and(|(session, parent)| session == parent)
121 {
122 return None;
123 }
124 current.parent.clone()
125}
126
127pub fn identity_cursor_path(repo_root: &Path) -> std::path::PathBuf {
132 let marker = repo_root.join(".heddle");
133 if marker.is_file() {
134 repo_root.join(".heddle.identity")
135 } else {
136 marker.join(IDENTITY_CURSOR_FILE)
137 }
138}
139
140pub fn last_turn_anchor_path(repo_root: &Path) -> std::path::PathBuf {
143 let marker = repo_root.join(".heddle");
144 if marker.is_file() {
145 repo_root.join(".heddle.last-turn")
146 } else {
147 marker.join(LAST_TURN_ANCHOR_FILE)
148 }
149}
150
151pub fn read_last_turn_anchor(repo_root: &Path) -> Option<LastTurnAnchor> {
154 let bytes = fs::read(last_turn_anchor_path(repo_root)).ok()?;
155 serde_json::from_slice(&bytes).ok()
156}
157
158pub fn write_last_turn_anchor(repo_root: &Path, anchor: &LastTurnAnchor) -> io::Result<()> {
160 let dest = last_turn_anchor_path(repo_root);
161 if let Some(parent) = dest.parent() {
162 fs::create_dir_all(parent)?;
163 }
164 static LAST_TURN_TMP_SEQ: AtomicU64 = AtomicU64::new(0);
165 let tmp = dest.with_file_name(format!(
166 ".last-turn.tmp.{}.{}",
167 std::process::id(),
168 LAST_TURN_TMP_SEQ.fetch_add(1, Ordering::Relaxed)
169 ));
170 let body = serde_json::to_vec(anchor)
171 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
172 fs::write(&tmp, body)?;
173 let renamed = fs::rename(&tmp, &dest);
174 if renamed.is_err() {
175 let _ = fs::remove_file(&tmp);
176 }
177 renamed
178}
179
180pub fn read_identity_cursor(repo_root: &Path) -> IdentityCursor {
182 read_identity_cursor_unlocked(repo_root)
183}
184
185fn read_identity_cursor_unlocked(repo_root: &Path) -> IdentityCursor {
186 let path = identity_cursor_path(repo_root);
187 let Ok(bytes) = fs::read(&path) else {
188 return IdentityCursor::default();
189 };
190 serde_json::from_slice::<IdentityCursor>(&bytes)
191 .unwrap_or_default()
192 .omit_unpublished()
193}
194
195pub fn write_identity_cursor(repo_root: &Path, cursor: &IdentityCursor) -> io::Result<()> {
197 let dest = identity_cursor_path(repo_root);
198 let _guard = acquire_identity_lock(&dest)?;
199 write_identity_cursor_unlocked(repo_root, cursor)
200}
201
202fn write_identity_cursor_unlocked(repo_root: &Path, cursor: &IdentityCursor) -> io::Result<()> {
203 let dest = identity_cursor_path(repo_root);
204 if let Some(parent) = dest.parent() {
205 fs::create_dir_all(parent)?;
206 }
207 static TMP_SEQ: AtomicU64 = AtomicU64::new(0);
208 let tmp = dest.with_file_name(format!(
209 ".identity.tmp.{}.{}",
210 std::process::id(),
211 TMP_SEQ.fetch_add(1, Ordering::Relaxed)
212 ));
213 let body = cursor
214 .clone()
215 .omit_unpublished()
216 .to_vec()
217 .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
218 fs::write(&tmp, &body)?;
219 let renamed = fs::rename(&tmp, dest);
220 if renamed.is_err() {
221 let _ = fs::remove_file(&tmp);
222 }
223 renamed
224}
225
226pub fn stamp_identity_cursor(
231 repo_root: &Path,
232 patch: &IdentityCursor,
233) -> io::Result<IdentityCursor> {
234 let dest = identity_cursor_path(repo_root);
235 let _guard = acquire_identity_lock(&dest)?;
236 let current = read_identity_cursor_unlocked(repo_root);
237 let next = current.merge_event(patch);
238 write_identity_cursor_unlocked(repo_root, &next)?;
239 Ok(next)
240}
241
242pub fn expire_identity_cursor(repo_root: &Path) -> io::Result<()> {
244 let dest = identity_cursor_path(repo_root);
245 let _guard = acquire_identity_lock(&dest)?;
246 match fs::remove_file(&dest) {
247 Ok(()) => Ok(()),
248 Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
249 Err(err) => Err(err),
250 }
251}
252
253struct IdentityWriteGuard {
254 file: fs::File,
255}
256
257fn acquire_identity_lock(dest: &Path) -> io::Result<IdentityWriteGuard> {
258 let lock_path = dest.with_file_name(".identity.lock");
259 if let Some(parent) = lock_path.parent() {
260 fs::create_dir_all(parent)?;
261 }
262 let file = OpenOptions::new()
263 .create(true)
264 .truncate(false)
265 .read(true)
266 .write(true)
267 .open(&lock_path)?;
268 file.lock_exclusive()?;
269 Ok(IdentityWriteGuard { file })
270}
271
272impl Drop for IdentityWriteGuard {
273 fn drop(&mut self) {
274 let _ = self.file.unlock();
275 }
276}
277
278pub fn value_string_or_named(value: &Value, path: &[&str], object_keys: &[&str]) -> Option<String> {
280 let mut current = value;
281 for segment in path {
282 current = current.get(*segment)?;
283 }
284 match current {
285 Value::String(s) => published_owned(Some(s.clone())),
286 Value::Bool(v) => Some(v.to_string()),
287 Value::Number(v) => Some(v.to_string()),
288 Value::Object(obj) => object_keys
289 .iter()
290 .find_map(|key| obj.get(*key).and_then(Value::as_str))
291 .and_then(|s| published_owned(Some(s.to_string()))),
292 _ => None,
293 }
294}
295
296pub fn thought_level_from_payload(payload: &Value) -> Option<String> {
298 value_string_or_named(payload, &["effort"], &["level"])
299 .or_else(|| first_value_string(payload, &[&["thought_level"], &["reasoning_effort"]]))
300 .or_else(|| value_string(payload, &["model", "variant"]))
301 .or_else(|| value_string(payload, &["turn_context", "effort"]))
302 .or_else(|| value_string(payload, &["turn_context", "reasoning_effort"]))
303 .and_then(|s| published_owned(Some(s)))
304}
305
306pub fn harness_kind_from_basename(argv0: &str) -> Option<&'static str> {
308 crate::harness_policy::detect_harness_kind(Some(argv0), &Default::default()).as_str()
309}
310
311#[cfg(test)]
312mod tests {
313 use serde_json::json;
314
315 use super::*;
316
317 #[test]
318 fn merge_keeps_last_cursor_when_event_omits_field() {
319 let current = IdentityCursor {
320 provider: Some("anthropic".into()),
321 model: Some("opus".into()),
322 thought_level: Some("high".into()),
323 session: Some("sess-1".into()),
324 parent: Some("agent-1".into()),
325 };
326 let next = current.merge_event(&IdentityCursor {
327 thought_level: Some("low".into()),
328 ..IdentityCursor::default()
329 });
330 assert_eq!(next.model.as_deref(), Some("opus"));
331 assert_eq!(next.thought_level.as_deref(), Some("low"));
332 assert_eq!(next.session.as_deref(), Some("sess-1"));
333 assert_eq!(next.parent.as_deref(), Some("agent-1"));
334 }
335
336 #[test]
337 fn parent_session_event_clears_stale_subagent_parent() {
338 let current = IdentityCursor {
339 provider: Some("openai".into()),
340 model: Some("gpt-5.4".into()),
341 thought_level: None,
342 session: Some("sub-1".into()),
343 parent: Some("parent-1".into()),
344 };
345 let back_on_parent = current.merge_event(&IdentityCursor {
346 session: Some("parent-1".into()),
347 ..IdentityCursor::default()
348 });
349 assert_eq!(back_on_parent.session.as_deref(), Some("parent-1"));
350 assert!(
351 back_on_parent.parent.is_none(),
352 "parent-session event must not keep the subagent parent"
353 );
354
355 let still_subagent = current.merge_event(&IdentityCursor {
356 session: Some("sub-1".into()),
357 thought_level: Some("low".into()),
358 ..IdentityCursor::default()
359 });
360 assert_eq!(still_subagent.parent.as_deref(), Some("parent-1"));
361 assert_eq!(still_subagent.thought_level.as_deref(), Some("low"));
362 }
363
364 #[test]
365 fn claude_main_agent_event_clears_subagent_parent() {
366 let current = IdentityCursor {
367 provider: Some("anthropic".into()),
368 model: Some("opus".into()),
369 thought_level: None,
370 session: Some("sess-1".into()),
371 parent: Some("agent-sub".into()),
372 };
373 let back_on_main = current.merge_event(&IdentityCursor {
374 session: Some("sess-1".into()),
375 parent: Some("sess-1".into()),
376 ..IdentityCursor::default()
377 });
378 assert_eq!(back_on_main.session.as_deref(), Some("sess-1"));
379 assert!(
380 back_on_main.parent.is_none(),
381 "main-agent event (parent == session) must clear the subagent parent"
382 );
383 }
384
385 #[test]
386 fn omit_unpublished_drops_unknown_and_empty() {
387 let cursor = IdentityCursor {
388 provider: Some("anthropic".into()),
389 model: Some("unknown".into()),
390 thought_level: Some("".into()),
391 session: Some(" ".into()),
392 parent: None,
393 }
394 .omit_unpublished();
395 assert_eq!(cursor.provider.as_deref(), Some("anthropic"));
396 assert!(cursor.model.is_none());
397 assert!(cursor.thought_level.is_none());
398 assert!(cursor.session.is_none());
399 }
400
401 #[test]
402 fn effort_level_object_maps_to_thought_level() {
403 let payload = json!({"effort": {"level": "high"}, "session_id": "s1"});
404 assert_eq!(
405 thought_level_from_payload(&payload).as_deref(),
406 Some("high")
407 );
408 assert_eq!(
409 value_string_or_named(&payload, &["effort"], &["level"]).as_deref(),
410 Some("high")
411 );
412 assert!(value_string(&payload, &["effort"]).is_none());
413 }
414
415 #[test]
416 fn basename_kind_is_exact_not_path_contains() {
417 assert_eq!(
418 harness_kind_from_basename("/home/u/dev/codex/target/debug/heddle"),
419 None
420 );
421 assert_eq!(harness_kind_from_basename("/usr/bin/codex"), Some("codex"));
422 assert_eq!(
423 harness_kind_from_basename("/usr/local/bin/claude"),
424 Some("claude-code")
425 );
426 }
427
428 #[test]
429 fn write_and_read_roundtrip_omits_unpublished() {
430 let dir = tempfile::TempDir::new().unwrap();
431 let written = stamp_identity_cursor(
432 dir.path(),
433 &IdentityCursor {
434 provider: Some("anthropic".into()),
435 model: Some("opus".into()),
436 thought_level: None,
437 session: Some("s1".into()),
438 parent: None,
439 },
440 )
441 .unwrap();
442 assert!(written.thought_level.is_none());
443 let raw = fs::read_to_string(identity_cursor_path(dir.path())).unwrap();
444 assert!(!raw.contains("thought_level"));
445 assert!(!raw.contains("parent"));
446 assert_eq!(read_identity_cursor(dir.path()), written);
447 }
448
449 #[test]
450 fn concurrent_stamps_use_unique_tmp_and_leave_valid_cursor() {
451 let dir = tempfile::TempDir::new().unwrap();
452 std::thread::scope(|scope| {
453 for i in 0..8 {
454 let root = dir.path();
455 scope.spawn(move || {
456 stamp_identity_cursor(
457 root,
458 &IdentityCursor {
459 provider: Some("anthropic".into()),
460 model: Some(format!("m{i}")),
461 ..IdentityCursor::default()
462 },
463 )
464 .unwrap();
465 });
466 }
467 });
468 let cursor = read_identity_cursor(dir.path());
469 assert_eq!(cursor.provider.as_deref(), Some("anthropic"));
470 assert!(
471 cursor
472 .model
473 .as_deref()
474 .is_some_and(|model| model.starts_with('m')),
475 "last writer must leave a published model, got {:?}",
476 cursor.model
477 );
478 let leftovers: Vec<_> = fs::read_dir(dir.path())
479 .unwrap()
480 .filter_map(|entry| entry.ok())
481 .map(|entry| entry.file_name())
482 .filter(|name| name.to_string_lossy().starts_with(".identity.tmp."))
483 .collect();
484 assert!(
485 leftovers.is_empty(),
486 "unique tmp files must be renamed away"
487 );
488 }
489
490 #[test]
491 fn two_writer_merge_keeps_model_and_thought_level() {
492 let dir = tempfile::TempDir::new().unwrap();
493 stamp_identity_cursor(
494 dir.path(),
495 &IdentityCursor {
496 provider: Some("anthropic".into()),
497 ..IdentityCursor::default()
498 },
499 )
500 .unwrap();
501 let ready = std::sync::Barrier::new(2);
502 std::thread::scope(|scope| {
503 scope.spawn(|| {
504 ready.wait();
505 for _ in 0..40 {
506 stamp_identity_cursor(
507 dir.path(),
508 &IdentityCursor {
509 model: Some("opus".into()),
510 ..IdentityCursor::default()
511 },
512 )
513 .unwrap();
514 }
515 });
516 scope.spawn(|| {
517 ready.wait();
518 for _ in 0..40 {
519 stamp_identity_cursor(
520 dir.path(),
521 &IdentityCursor {
522 thought_level: Some("high".into()),
523 ..IdentityCursor::default()
524 },
525 )
526 .unwrap();
527 }
528 });
529 });
530 let cursor = read_identity_cursor(dir.path());
531 assert_eq!(cursor.provider.as_deref(), Some("anthropic"));
532 assert_eq!(
533 cursor.model.as_deref(),
534 Some("opus"),
535 "StatusLine model must survive a concurrent PreToolUse effort stamp"
536 );
537 assert_eq!(
538 cursor.thought_level.as_deref(),
539 Some("high"),
540 "PreToolUse effort must survive a concurrent StatusLine model stamp"
541 );
542 }
543
544 #[test]
545 fn expire_removes_cursor_so_later_read_is_empty() {
546 let dir = tempfile::TempDir::new().unwrap();
547 stamp_identity_cursor(
548 dir.path(),
549 &IdentityCursor {
550 provider: Some("anthropic".into()),
551 model: Some("opus".into()),
552 ..IdentityCursor::default()
553 },
554 )
555 .unwrap();
556 expire_identity_cursor(dir.path()).unwrap();
557 assert!(read_identity_cursor(dir.path()).is_empty());
558 assert!(!identity_cursor_path(dir.path()).exists());
559 }
560}