Skip to main content

verbs/
identity_cursor.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Current harness identity cursor (`provider`, `model`, `thought_level`,
3//! `session`, `parent`) written by install hooks and frozen onto each capture.
4//!
5//! This is live cursor state, not “the model of the thread”. Mid-thread
6//! `/model` or `/effort` updates the cursor only; already-captured states
7//! keep the pair they froze.
8
9use 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
23/// Sidecar file name under `.heddle/` (not `identity.toml`, the signing key).
24pub const IDENTITY_CURSOR_FILE: &str = "identity";
25const LAST_TURN_ANCHOR_FILE: &str = "last-turn";
26
27/// Workspace-local link between a live harness session and its first capture.
28#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
29pub struct LastTurnAnchor {
30    pub session_id: String,
31    pub state_id: StateId,
32}
33
34/// ACP-named current identity. Omit unpublished fields.
35#[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    /// Drop empty / `unknown` placeholders so unpublished fields stay omitted.
51    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    /// Merge an event patch: missing incoming fields keep the last cursor value.
61    ///
62    /// `parent` is the exception:
63    /// - incoming `parent` equal to incoming `session` is the main agent
64    ///   (Claude omits `agent_id`) and clears a stale subagent parent
65    /// - incoming `session` equal to the stored parent (Codex parent-session)
66    ///   also clears it
67    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    /// Compact JSON (~200 bytes) for the sidecar hot path.
88    pub fn to_vec(&self) -> Result<Vec<u8>, serde_json::Error> {
89        serde_json::to_vec(self)
90    }
91}
92
93/// Treat empty / `unknown` as unpublished.
94pub 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
127/// Workspace sidecar path.
128///
129/// Directory checkout: `.heddle/identity`. Pointer checkout (`.heddle` is a
130/// file): `.heddle.identity` beside it so the cursor stays workspace-local.
131pub 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
140/// Workspace-local last-turn anchor path. Pointer checkouts keep it beside
141/// `.heddle`, matching the identity cursor's isolation behavior.
142pub 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
151/// Read the current last-turn anchor. Missing or malformed reconstructible
152/// metadata is treated as absent so callers fail closed.
153pub 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
158/// Atomically publish the first capture for a harness session.
159pub 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
180/// Read the current cursor. Missing or unreadable → empty cursor.
181pub 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
195/// Atomic rename of a reconstructible sidecar. No fsync — next hook rewrites.
196pub 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
226/// Merge `patch` onto the on-disk cursor and publish it.
227///
228/// Holds an exclusive sidecar lock for the read-merge-write so StatusLine
229/// (model) and PreToolUse (effort) cannot clobber each other.
230pub 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
242/// Drop the live cursor so a later human/Cursor capture cannot freeze a dead session.
243pub 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
278/// Walk `path` for a string, or an object's `id` / `level` field.
279pub 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
296/// Claude / Codex / OpenCode thought_level mapping (ACP name, not wire).
297pub 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
306/// Exact ancestor basename → harness kind token. Never a model.
307pub 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}