Skip to main content

agentmux_srv/backend/
continuity_segments.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Segment index (SPEC_DURABLE_CONVERSATION_MEMORY_2026_09_23.md §4.1, P1):
5//! one record per provider session an agent has run, so "this agent's
6//! conversation" is a chain AgentMux owns rather than whatever session id a
7//! pane happens to hold.
8//!
9//! Stored as an append-only event log, `segments.jsonl` in the global
10//! transcript store's `agent-uid:<uid>:segments` zone, not as a SQL table
11//! (§4.1.1): that store is shared by every srv on the machine and takes
12//! additions without a schema version bump, where a new table in
13//! `identity-store.db` would bump its version and lock older builds out of
14//! it (`check_schema_compat`). Events: `start` at spawn, `session` when a
15//! fresh process reports its provider session id, `end` when the process
16//! goes away. [`segments`] folds them back into one row per segment.
17//!
18//! The zone is keyed by the agent's UID (`AGENTMUX_AGENT_UID`), not its
19//! definition id: a template launch shares the template's transcript zone
20//! but is a different agent. It is deliberately not the transcript zone, so
21//! "New conversation" (which clears `agent:<defId>:current`) never erases the
22//! chain; a new conversation is just a segment whose rung is `fresh`.
23
24use serde::{Deserialize, Serialize};
25
26use crate::backend::storage::filestore::{FileMeta, FileOpts, FileStore};
27
28pub(crate) const SEGMENTS_FILE: &str = "segments.jsonl";
29/// Enough of the log's end to find the newest segment for a predecessor link
30/// without reading an agent's whole history.
31const TAIL_WINDOW_BYTES: i64 = 64 * 1024;
32
33/// How a segment began (§4.2's rungs; `relocated` arrives with P4).
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub(crate) enum Rung {
37    /// The provider resumed the previous session.
38    Native,
39    /// A fresh provider session, given AgentMux's record of the conversation.
40    Virtualized,
41    /// A fresh provider session with nothing carried over.
42    Fresh,
43}
44
45/// Why a segment's process went away.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub(crate) enum EndReason {
49    /// Exited on its own with status 0.
50    Exited,
51    /// Exited on its own with a non-zero status.
52    Crashed,
53    /// The pane was closed.
54    Closed,
55    /// Stopped to apply a runtime-config change; the next spawn resumes it.
56    Restarted,
57    /// Stopped for any other reason (user stop, controller replaced).
58    Stopped,
59}
60
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
62#[serde(tag = "event", rename_all = "snake_case")]
63enum Event {
64    Start(Start),
65    Session { segment_id: String, provider_session_id: String, at_ms: i64 },
66    End {
67        segment_id: String,
68        ended_at_ms: i64,
69        end_reason: EndReason,
70        byte_end: Option<i64>,
71        /// Size of the provider's session file when the process went away
72        /// (spec §4.4). `None`: unknown, or a record from before the field.
73        #[serde(default, skip_serializing_if = "Option::is_none")]
74        provider_bytes_end: Option<i64>,
75    },
76}
77
78/// What is known about a segment when its process starts.
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
80pub(crate) struct Start {
81    pub segment_id: String,
82    pub agent_uid: String,
83    /// The block's `agentId`: the transcript zone's key, which differs from
84    /// `agent_uid` for a template launch.
85    pub definition_id: Option<String>,
86    pub provider: String,
87    /// Where native resume would have to look (`CLAUDE_CONFIG_DIR`, …).
88    pub config_dir: Option<String>,
89    /// Known up front on a resume; otherwise filled in by a `session` event.
90    pub provider_session_id: Option<String>,
91    pub cwd: String,
92    pub channel: String,
93    pub agentmux_version: String,
94    pub block_id: String,
95    /// The global transcript zone this segment appends to, and its size then.
96    pub zone: Option<String>,
97    pub byte_start: Option<i64>,
98    pub started_at_ms: i64,
99    pub continuity_rung: Rung,
100    pub predecessor_segment_id: Option<String>,
101    /// The single-live-instance lease epoch this segment's process held
102    /// (SPEC_AGENT_SINGLE_LIVE_INSTANCE_2026_09_24 Phase 3). A change between
103    /// consecutive segments is an ownership change — another instance, or a
104    /// takeover — visible in the ledger. `None`: no lease (older build, no
105    /// UID) or a record written before this field.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub lease_epoch: Option<u64>,
108    /// The Anthropic identity `config_dir` was signed in as
109    /// (`account_email::identity_key_from_oauth_dir`,
110    /// SPEC_RESUME_GATE_AND_SAME_IDENTITY_CONTINUATION_2026_09_25.md §4.1).
111    /// `None`: unknown, or a record written before this field.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub identity_key: Option<String>,
114    /// The session this spawn resumed with `--fork-session` after
115    /// relocating it from another login of the same identity (spec §4.3).
116    /// Its own id arrives in the segment's `session` event.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub forked_from: Option<String>,
119}
120
121/// One segment, folded from its events.
122#[derive(Debug, Clone, PartialEq)]
123pub(crate) struct Segment {
124    pub start: Start,
125    pub ended_at_ms: Option<i64>,
126    pub end_reason: Option<EndReason>,
127    pub byte_end: Option<i64>,
128    pub provider_bytes_end: Option<i64>,
129}
130
131/// `agent-uid:<uid>:segments`, or `None` for a UID outside the safe zone-name
132/// character set.
133pub(crate) fn zone_for(agent_uid: &str) -> Option<String> {
134    crate::backend::agent_session::is_valid_definition_id(agent_uid).then(|| format!("agent-uid:{agent_uid}:segments"))
135}
136
137fn append(fs: &FileStore, zone: &str, event: &Event) -> Result<(), String> {
138    let mut line = serde_json::to_vec(event).map_err(|e| format!("encode segment event: {e}"))?;
139    line.push(b'\n');
140    if fs.stat(zone, SEGMENTS_FILE).map_err(|e| format!("stat segments: {e}"))?.is_none() {
141        // Two srvs can race to create it; losing that race is fine.
142        let _ = fs.make_file(zone, SEGMENTS_FILE, FileMeta::default(), FileOpts::default());
143    }
144    fs.append_data(zone, SEGMENTS_FILE, &line).map_err(|e| format!("append segment event: {e}"))
145}
146
147fn events_in(bytes: &[u8]) -> impl Iterator<Item = Event> + '_ {
148    bytes.split(|b| *b == b'\n').filter_map(|line| serde_json::from_slice::<Event>(line).ok())
149}
150
151/// The id of the agent's newest segment, from the end of its log.
152fn newest_segment_id(fs: &FileStore, zone: &str) -> Option<String> {
153    let file = fs.stat(zone, SEGMENTS_FILE).ok()??;
154    let offset = (file.size - TAIL_WINDOW_BYTES).max(0);
155    let (_, data) = fs.read_at(zone, SEGMENTS_FILE, offset, file.size - offset).ok()?;
156    let mut newest: Option<(i64, String)> = None;
157    for event in events_in(&data) {
158        if let Event::Start(s) = event {
159            if newest.as_ref().is_none_or(|(at, _)| s.started_at_ms >= *at) {
160                newest = Some((s.started_at_ms, s.segment_id));
161            }
162        }
163    }
164    newest.map(|(_, id)| id)
165}
166
167/// Records a segment's start and returns its id. `start.segment_id` and
168/// `start.predecessor_segment_id` are filled in here.
169pub(crate) fn record_start(fs: &FileStore, mut start: Start) -> Result<String, String> {
170    let zone = zone_for(&start.agent_uid).ok_or("agent uid is not a valid zone key")?;
171    start.segment_id = uuid::Uuid::new_v4().to_string();
172    start.predecessor_segment_id = newest_segment_id(fs, &zone);
173    let id = start.segment_id.clone();
174    append(fs, &zone, &Event::Start(start))?;
175    Ok(id)
176}
177
178pub(crate) fn record_session(fs: &FileStore, agent_uid: &str, segment_id: &str, provider_session_id: &str, at_ms: i64) -> Result<(), String> {
179    let zone = zone_for(agent_uid).ok_or("agent uid is not a valid zone key")?;
180    append(
181        fs,
182        &zone,
183        &Event::Session { segment_id: segment_id.to_string(), provider_session_id: provider_session_id.to_string(), at_ms },
184    )
185}
186
187#[cfg(test)]
188pub(crate) fn record_end(
189    fs: &FileStore,
190    agent_uid: &str,
191    segment_id: &str,
192    end_reason: EndReason,
193    byte_end: Option<i64>,
194    ended_at_ms: i64,
195) -> Result<(), String> {
196    record_end_with_provider_bytes(fs, agent_uid, segment_id, end_reason, byte_end, None, ended_at_ms)
197}
198
199/// [`record_end`], also recording the provider session file's size then
200/// (spec §4.4), so a later spawn can tell whether something outside
201/// AgentMux wrote to it since.
202pub(crate) fn record_end_with_provider_bytes(
203    fs: &FileStore,
204    agent_uid: &str,
205    segment_id: &str,
206    end_reason: EndReason,
207    byte_end: Option<i64>,
208    provider_bytes_end: Option<i64>,
209    ended_at_ms: i64,
210) -> Result<(), String> {
211    let zone = zone_for(agent_uid).ok_or("agent uid is not a valid zone key")?;
212    append(fs, &zone, &Event::End { segment_id: segment_id.to_string(), ended_at_ms, end_reason, byte_end, provider_bytes_end })
213}
214
215/// Every segment the agent has run, oldest first. A segment with no `end` is
216/// running, or its srv died before it could record one.
217pub(crate) fn segments(fs: &FileStore, agent_uid: &str) -> Vec<Segment> {
218    let Some(zone) = zone_for(agent_uid) else { return Vec::new() };
219    let Ok(Some(data)) = fs.read_file(&zone, SEGMENTS_FILE) else { return Vec::new() };
220    fold(events_in(&data))
221}
222
223fn fold(events: impl Iterator<Item = Event>) -> Vec<Segment> {
224    let mut out: Vec<Segment> = Vec::new();
225    for event in events {
226        match event {
227            Event::Start(start) => {
228                out.push(Segment { start, ended_at_ms: None, end_reason: None, byte_end: None, provider_bytes_end: None });
229            }
230            Event::Session { segment_id, provider_session_id, .. } => {
231                if let Some(s) = out.iter_mut().rev().find(|s| s.start.segment_id == segment_id) {
232                    s.start.provider_session_id = Some(provider_session_id);
233                }
234            }
235            Event::End { segment_id, ended_at_ms, end_reason, byte_end, provider_bytes_end } => {
236                // Closed exactly once: the first `end` wins.
237                if let Some(s) = out.iter_mut().rev().find(|s| s.start.segment_id == segment_id && s.ended_at_ms.is_none()) {
238                    s.ended_at_ms = Some(ended_at_ms);
239                    s.end_reason = Some(end_reason);
240                    s.byte_end = byte_end;
241                    s.provider_bytes_end = provider_bytes_end;
242                }
243            }
244        }
245    }
246    out.sort_by_key(|s| s.start.started_at_ms);
247    out
248}
249
250/// The provider session the agent's conversation was last in: the session
251/// of the newest segment, other than `current` (the spawn now running or
252/// just failed), that has one. `None` when the agent has no such segment.
253pub(crate) fn head_session(segments: &[Segment], current: Option<&str>) -> Option<String> {
254    segments
255        .iter()
256        .rev()
257        .filter(|s| Some(s.start.segment_id.as_str()) != current)
258        .find_map(|s| s.start.provider_session_id.clone())
259}
260
261/// Whether a resume-failure recovery may continue `candidate` (§4.2: only
262/// the head of the chain is this conversation). With a chain, any other
263/// session is older or unrelated history. It's typically the same agent's
264/// session from the last time it ran under this account, and resuming it
265/// would show "Resumed" on the wrong conversation, so the caller falls
266/// through to a fresh session carrying the continuation packet. With no
267/// known head (an agent from before the segment index, whose only segment is
268/// the current one), nothing better is known and recovery works as before.
269pub(crate) fn recovery_allowed(candidate: &str, head: Option<&str>) -> bool {
270    head.is_none_or(|h| h == candidate)
271}
272
273/// What the resume gate decided for a session a spawn is about to `--resume`
274/// (SPEC_RESUME_GATE_AND_SAME_IDENTITY_CONTINUATION_2026_09_25.md §4.2).
275#[derive(Debug, Clone, PartialEq, Eq)]
276pub(crate) enum ResumeGate {
277    /// Resume the candidate: it is the chain head, or the chain can't say
278    /// otherwise.
279    Allow,
280    /// The conversation moved on to `head`, reachable here: resume that.
281    Redirect { head: String },
282    /// The conversation moved on to `head`, which this spawn can't reach:
283    /// resume nothing, so the spawn starts fresh and carries the record.
284    Refuse { head: String },
285    /// `head` lives under another login of the same identity, readable at
286    /// `from_config_dir`: copy it in and resume it with `--fork-session`
287    /// (spec §4.3).
288    Relocate { head: String, from_config_dir: String },
289    /// `head` is reachable here, but its file grew after AgentMux last saw
290    /// it: the conversation continued outside AgentMux. Resume it with
291    /// `--fork-session`, so that branch is never appended to by a second
292    /// writer (spec §4.4, H4).
293    Fork { head: String },
294    /// This agent has no conversation of its own yet, and the agent it was
295    /// forked from is at the candidate (`GateInput::fork_source`): copy that
296    /// session in from `from_config_dir` / `from_cwd` (a fork works in its own
297    /// directory, so the CLI can't find it there) and resume it with
298    /// `--fork-session`, so the fork starts from its parent's conversation and
299    /// never writes to it (the fork-isolation report in #4744, F5).
300    ForkFrom { head: String, from_config_dir: String, from_cwd: String },
301}
302
303/// What the gate weighs about the spawn.
304#[derive(Debug, Clone, Copy, Default)]
305pub(crate) struct GateInput<'a> {
306    /// The session the spawn would `--resume`.
307    pub candidate: &'a str,
308    pub head: Option<&'a Head>,
309    /// The spawn's identity key.
310    pub identity: Option<&'a str>,
311    /// An id this controller already saw the CLI reject.
312    pub poisoned: Option<&'a str>,
313    /// The spawn's provider config dir and working dir.
314    pub config_dir: &'a str,
315    pub cwd: &'a str,
316    /// The chain head of the agent this one was forked from, when this one
317    /// has none of its own (`fork_source_head`).
318    pub fork_source: Option<&'a Head>,
319}
320
321/// Where the agent's conversation was last: the newest segment that has a
322/// provider session, and what that segment recorded about it.
323#[derive(Debug, Clone, Default, PartialEq, Eq)]
324pub(crate) struct Head {
325    pub session_id: String,
326    pub identity_key: Option<String>,
327    pub config_dir: Option<String>,
328    pub provider: String,
329    pub cwd: String,
330    /// The provider file's size when the head's segment ended, when known.
331    pub provider_bytes_end: Option<i64>,
332}
333
334/// Only the chain head is resumed natively (spec §3 I1), and never across
335/// identities (I2). `input.head` is the agent's [`chain_head`]; `None` (an
336/// agent from before the segment index, or no readable chain) allows the
337/// candidate, as before the gate existed. A head equal to the poisoned id
338/// allows too: that head is known dead, so the chain has nothing better to
339/// offer than the candidate. The identity check refuses only when both
340/// sides are known and differ. A head recorded under the spawn's own config
341/// dir whose file isn't there is refused too: the provider never wrote it.
342///
343/// `size_here(sid)`: the size of the session's file under the spawn's
344/// config dir and cwd, `None` when it isn't there. `resumable_at(config_dir,
345/// cwd, sid)`: a session file for that working dir exists under that config
346/// dir with a complete record to copy (§4.3; its last record may still be
347/// being written, and the copy is cut back to a complete one).
348pub(crate) fn resume_gate(
349    input: &GateInput<'_>,
350    size_here: impl Fn(&str) -> Option<u64>,
351    resumable_at: impl Fn(&str, &str, &str) -> bool,
352) -> ResumeGate {
353    let Some(head) = input.head else { return fork_from(input, &resumable_at).unwrap_or(ResumeGate::Allow) };
354    if Some(head.session_id.as_str()) == input.poisoned {
355        return ResumeGate::Allow;
356    }
357    let refuse = || ResumeGate::Refuse { head: head.session_id.clone() };
358    if matches!((head.identity_key.as_deref(), input.identity), (Some(h), Some(s)) if h != s) {
359        return refuse();
360    }
361    let is_candidate = head.session_id == input.candidate;
362    if let Some(size) = size_here(&head.session_id) {
363        if head.provider_bytes_end.is_some_and(|end| i64::try_from(size).unwrap_or(i64::MAX) > end) {
364            return ResumeGate::Fork { head: head.session_id.clone() };
365        }
366        return if is_candidate { ResumeGate::Allow } else { ResumeGate::Redirect { head: head.session_id.clone() } };
367    }
368    if let Some(from) = relocation_source(head, input) {
369        if resumable_at(from, input.cwd, &head.session_id) {
370            return ResumeGate::Relocate { head: head.session_id.clone(), from_config_dir: from.to_string() };
371        }
372    }
373    // Recorded under this very config dir, yet its file isn't here: Claude
374    // never wrote it (a session is saved with its first turn, and this one was
375    // restarted before it had one), or it was deleted since. `--resume` can
376    // only fail, and the user would see the CLI's error; start fresh with the
377    // record instead.
378    if head.config_dir.as_deref().is_some_and(|dir| same_dir(dir, input.config_dir)) {
379        return refuse();
380    }
381    // The head is out of reach and can't be relocated. As the candidate, it
382    // goes on to `--resume` as before the gate: the CLI's rejection and the
383    // retry path already handle it. As anything else, nothing is resumed.
384    if is_candidate {
385        ResumeGate::Allow
386    } else {
387        refuse()
388    }
389}
390
391/// The head's config dir when §4.3's metadata preconditions hold: a Claude
392/// session, under another config dir than the spawn's, recorded under the
393/// spawn's own identity (both known, I2), for the same project dir.
394fn relocation_source<'h>(head: &'h Head, input: &GateInput<'_>) -> Option<&'h str> {
395    if !matches!(head.provider.as_str(), "claude" | "claude-code") {
396        return None;
397    }
398    let from = head.config_dir.as_deref()?;
399    if same_dir(from, input.config_dir) {
400        return None;
401    }
402    if head.identity_key.as_deref()? != input.identity? {
403        return None;
404    }
405    let slug = crate::backend::claude_layout::project_dir_name;
406    (slug(&head.cwd) == slug(input.cwd)).then_some(from)
407}
408
409/// `ResumeGate::ForkFrom` when this agent has no chain and the agent it was
410/// forked from is at the candidate: a Claude session, under the spawn's own
411/// identity (both known, I2), whose file holds a complete record where it
412/// was recorded.
413fn fork_from(input: &GateInput<'_>, resumable_at: &impl Fn(&str, &str, &str) -> bool) -> Option<ResumeGate> {
414    let source = input.fork_source?;
415    if source.session_id != input.candidate || !matches!(source.provider.as_str(), "claude" | "claude-code") {
416        return None;
417    }
418    if source.identity_key.as_deref()? != input.identity? {
419        return None;
420    }
421    let from = source.config_dir.as_deref()?;
422    resumable_at(from, &source.cwd, &source.session_id).then(|| ResumeGate::ForkFrom {
423        head: source.session_id.clone(),
424        from_config_dir: from.to_string(),
425        from_cwd: source.cwd.clone(),
426    })
427}
428
429/// The chain head of the agent `agent_uid` was forked from (its definition
430/// has a `branch_label` and a `parent_id`): what a fork with no conversation
431/// of its own starts from (`ResumeGate::ForkFrom`). Shared by the spawn's
432/// gate and the pane's resume preflight.
433pub(crate) fn fork_source_head(
434    store: &crate::backend::storage::store::Store,
435    fs: &FileStore,
436    agent_uid: &str,
437) -> Option<Head> {
438    let def = store.agent_def_get(agent_uid).ok()??;
439    if def.branch_label.trim().is_empty() || def.parent_id.trim().is_empty() {
440        return None;
441    }
442    chain_head(fs, def.parent_id.trim()).map(with_legacy_identity)
443}
444
445fn same_dir(a: &str, b: &str) -> bool {
446    let norm = |s: &str| s.replace('\\', "/").trim_end_matches('/').to_lowercase();
447    norm(a) == norm(b)
448}
449
450/// A head whose segment predates `identity_key` (recorded by a build
451/// before #3839) takes the identity from its own config dir's
452/// `.claude.json`, so an agent's first upgrade from such a build can still
453/// relocate (spec §4.1). That file names whoever is signed in there now,
454/// which for an account's own dir is the login the session ran under unless
455/// the dir was since re-logged-into as someone else; a recorded key always
456/// wins. Unreadable (the dir is gone) stays unknown, which never relocates.
457pub(crate) fn with_legacy_identity(mut head: Head) -> Head {
458    if head.identity_key.is_none() {
459        if let Some(dir) = head.config_dir.as_deref() {
460            head.identity_key = crate::identity::account_email::identity_key_from_oauth_dir(&head.provider, dir);
461        }
462    }
463    head
464}
465
466/// Where the agent's conversation was last, read from its chain in `fs`.
467/// `None` when the UID has no chain.
468pub(crate) fn chain_head(fs: &FileStore, agent_uid: &str) -> Option<Head> {
469    segments(fs, agent_uid).into_iter().rev().find_map(|s| {
470        let session_id = s.start.provider_session_id?;
471        Some(Head {
472            session_id,
473            identity_key: s.start.identity_key,
474            config_dir: s.start.config_dir,
475            provider: s.start.provider,
476            cwd: s.start.cwd,
477            provider_bytes_end: s.provider_bytes_end,
478        })
479    })
480}
481
482/// The rung a spawn started on, from what the spawn knows: whether it passed
483/// `--resume`, and whether it carries a continuation packet.
484pub(crate) fn rung_for_spawn(resumed: bool, carries_packet: bool) -> Rung {
485    if resumed {
486        Rung::Native
487    } else if carries_packet {
488        Rung::Virtualized
489    } else {
490        Rung::Fresh
491    }
492}
493
494/// Why a killed process went away, from the controller's state at the kill.
495pub(crate) fn kill_reason(closing_this_generation: bool, restart_pending: bool) -> EndReason {
496    if closing_this_generation {
497        EndReason::Closed
498    } else if restart_pending {
499        EndReason::Restarted
500    } else {
501        EndReason::Stopped
502    }
503}
504
505pub(crate) fn exit_reason(exit_code: i32) -> EndReason {
506    if exit_code == 0 { EndReason::Exited } else { EndReason::Crashed }
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512
513    const UID: &str = "uid-1";
514
515    fn start(at: i64, rung: Rung) -> Start {
516        Start {
517            segment_id: String::new(),
518            agent_uid: UID.into(),
519            definition_id: Some("def-1".into()),
520            provider: "claude".into(),
521            config_dir: Some("C:/cfg/a".into()),
522            provider_session_id: None,
523            cwd: "C:/work".into(),
524            channel: "stable".into(),
525            agentmux_version: "0.57.2".into(),
526            block_id: "block-1".into(),
527            zone: Some("agent:def-1:current".into()),
528            byte_start: Some(100),
529            started_at_ms: at,
530            continuity_rung: rung,
531            predecessor_segment_id: None,
532            lease_epoch: None,
533            identity_key: None,
534            forked_from: None,
535        }
536    }
537
538    /// SPEC_AGENT_SINGLE_LIVE_INSTANCE_2026_09_24 Phase 3: each segment
539    /// records the lease epoch its process held, so an ownership change
540    /// between segments is visible in the ledger; a record written before
541    /// the field existed still reads, as `None`.
542    #[test]
543    fn segments_record_the_lease_epoch_and_older_records_still_read() {
544        let fs = FileStore::open_in_memory().unwrap();
545        let first = record_start(&fs, Start { lease_epoch: Some(3), ..start(1_000, Rung::Fresh) }).unwrap();
546        record_start(&fs, Start { lease_epoch: Some(4), ..start(2_000, Rung::Native) }).unwrap();
547        let all = segments(&fs, UID);
548        assert_eq!(all.iter().map(|s| s.start.lease_epoch).collect::<Vec<_>>(), vec![Some(3), Some(4)]);
549        assert_eq!(all[0].start.segment_id, first);
550
551        let old = serde_json::json!({
552            "event": "start", "segment_id": "s", "agent_uid": UID, "definition_id": null, "provider": "claude",
553            "config_dir": null, "provider_session_id": null, "cwd": "", "channel": "stable",
554            "agentmux_version": "0.57.0", "block_id": "b", "zone": null, "byte_start": null,
555            "started_at_ms": 1, "continuity_rung": "fresh", "predecessor_segment_id": null,
556        });
557        match serde_json::from_value::<Event>(old).unwrap() {
558            Event::Start(s) => assert_eq!(s.lease_epoch, None),
559            other => panic!("expected a start event, got {other:?}"),
560        }
561    }
562
563    #[test]
564    fn segments_chain_to_their_predecessor_and_fold_their_events() {
565        let fs = FileStore::open_in_memory().unwrap();
566        let first = record_start(&fs, start(1_000, Rung::Fresh)).unwrap();
567        record_session(&fs, UID, &first, "sid-a", 1_001).unwrap();
568        record_end(&fs, UID, &first, EndReason::Crashed, Some(900), 2_000).unwrap();
569        let second = record_start(&fs, start(3_000, Rung::Virtualized)).unwrap();
570
571        let all = segments(&fs, UID);
572        assert_eq!(all.len(), 2);
573        assert_eq!(all[0].start.segment_id, first);
574        assert_eq!(all[0].start.provider_session_id.as_deref(), Some("sid-a"));
575        assert_eq!((all[0].ended_at_ms, all[0].end_reason, all[0].byte_end), (Some(2_000), Some(EndReason::Crashed), Some(900)));
576        assert_eq!(all[0].start.predecessor_segment_id, None);
577        assert_eq!(all[1].start.predecessor_segment_id.as_deref(), Some(first.as_str()));
578        assert_eq!(all[1].start.continuity_rung, Rung::Virtualized);
579        assert!(all[1].ended_at_ms.is_none(), "still running");
580        assert_ne!(first, second);
581    }
582
583    // ── resume_gate (SPEC_RESUME_GATE_AND_SAME_IDENTITY_CONTINUATION §4.2) ──
584
585    fn head(sid: &str, identity: Option<&str>) -> Head {
586        Head { session_id: sid.into(), identity_key: identity.map(str::to_string), ..Default::default() }
587    }
588
589    /// The gate over a spawn in `C:/cfg/new` for `C:/work`, where `here` are
590    /// the sessions under the spawn's config dir and `elsewhere` the ones
591    /// complete under any other.
592    fn gate(candidate: &str, head: Option<&Head>, identity: Option<&str>, poisoned: Option<&str>, here: &[&str], elsewhere: &[&str]) -> ResumeGate {
593        let input = GateInput { candidate, head, identity, poisoned, config_dir: "C:/cfg/new", cwd: "C:/work", fork_source: None };
594        resume_gate(&input, |sid| here.contains(&sid).then_some(10), |_, _, sid| elsewhere.contains(&sid))
595    }
596
597    // A fork's first spawn (the fork-isolation report in #4744, F5).
598
599    fn fork_source(sid: &str, identity: Option<&str>) -> Head {
600        Head {
601            session_id: sid.into(),
602            identity_key: identity.map(str::to_string),
603            config_dir: Some("C:/cfg/parent".into()),
604            provider: "claude".into(),
605            cwd: "C:/parent".into(),
606            provider_bytes_end: None,
607        }
608    }
609
610    /// The gate for a fork with no chain, `source` its parent's head, whose
611    /// file is complete at the parent's own config dir and working dir.
612    fn fork_gate(candidate: &str, source: &Head, identity: Option<&str>, complete: bool) -> ResumeGate {
613        let input = GateInput {
614            candidate,
615            head: None,
616            identity,
617            poisoned: None,
618            config_dir: "C:/cfg/new",
619            cwd: "C:/work",
620            fork_source: Some(source),
621        };
622        resume_gate(&input, |_| None, |dir, cwd, sid| complete && dir == "C:/cfg/parent" && cwd == "C:/parent" && sid == candidate)
623    }
624
625    #[test]
626    fn a_fork_without_a_chain_forks_from_its_parents_head() {
627        let source = fork_source("p1", Some("k1"));
628        assert_eq!(
629            fork_gate("p1", &source, Some("k1"), true),
630            ResumeGate::ForkFrom { head: "p1".into(), from_config_dir: "C:/cfg/parent".into(), from_cwd: "C:/parent".into() }
631        );
632    }
633
634    #[test]
635    fn a_fork_forks_only_its_parents_complete_head_under_its_own_identity() {
636        let source = fork_source("p1", Some("k1"));
637        assert_eq!(fork_gate("p0", &source, Some("k1"), true), ResumeGate::Allow, "not the parent's head");
638        assert_eq!(fork_gate("p1", &source, Some("k2"), true), ResumeGate::Allow, "another identity (I2)");
639        assert_eq!(fork_gate("p1", &source, None, true), ResumeGate::Allow, "identity unknown");
640        assert_eq!(fork_gate("p1", &source, Some("k1"), false), ResumeGate::Allow, "no complete file there");
641    }
642
643    #[test]
644    fn the_chain_head_itself_is_resumed() {
645        let h = head("s2", None);
646        assert_eq!(gate("s2", Some(&h), None, None, &["s2"], &[]), ResumeGate::Allow);
647    }
648
649    #[test]
650    fn without_a_chain_the_candidate_is_resumed_as_before() {
651        assert_eq!(gate("s1", None, Some("k1"), None, &[], &[]), ResumeGate::Allow);
652    }
653
654    /// The operator's case: the pane holds S1, the conversation moved on to S2.
655    #[test]
656    fn a_stale_candidate_is_redirected_to_a_reachable_head() {
657        let h = head("s2", None);
658        assert_eq!(gate("s1", Some(&h), None, None, &["s1", "s2"], &[]), ResumeGate::Redirect { head: "s2".into() });
659    }
660
661    #[test]
662    fn a_stale_candidate_is_refused_when_the_head_is_out_of_reach() {
663        let h = head("s2", None);
664        assert_eq!(gate("s1", Some(&h), None, None, &["s1"], &[]), ResumeGate::Refuse { head: "s2".into() });
665    }
666
667    /// The head as the candidate but out of reach, and not relocatable: it
668    /// goes to `--resume` as before the gate; the CLI's rejection and the
669    /// retry path handle it.
670    #[test]
671    fn an_unreachable_head_as_the_candidate_is_left_to_the_retry_path() {
672        let h = head("s2", None);
673        assert_eq!(gate("s2", Some(&h), None, None, &[], &[]), ResumeGate::Allow);
674    }
675
676    /// A fresh session restarted before its first turn (a model change right
677    /// after an account switch): recorded under the spawn's own config dir,
678    /// never written there. Resuming it could only fail.
679    #[test]
680    fn a_head_never_written_under_the_spawns_own_dir_is_not_resumed() {
681        let h = Head { config_dir: Some("c:\\cfg\\new\\".into()), provider: "claude".into(), ..head("s2", Some("k1")) };
682        assert_eq!(gate("s2", Some(&h), Some("k1"), None, &[], &[]), ResumeGate::Refuse { head: "s2".into() });
683        assert_eq!(gate("s2", Some(&h), Some("k1"), None, &["s2"], &[]), ResumeGate::Allow, "written: resumed");
684    }
685
686    #[test]
687    fn a_poisoned_head_never_displaces_the_candidate() {
688        let h = head("s2", None);
689        assert_eq!(gate("s1", Some(&h), None, Some("s2"), &["s2"], &[]), ResumeGate::Allow);
690    }
691
692    // Identity (spec §3 I2, H3): never resume across identities.
693
694    /// The config dir was re-logged-into as someone else: the head's file is
695    /// here, but it belongs to another identity.
696    #[test]
697    fn the_head_under_another_identity_is_not_resumed() {
698        let h = head("s2", Some("k-old"));
699        assert_eq!(gate("s2", Some(&h), Some("k-new"), None, &["s2"], &[]), ResumeGate::Refuse { head: "s2".into() });
700        assert_eq!(gate("s1", Some(&h), Some("k-new"), None, &["s2"], &[]), ResumeGate::Refuse { head: "s2".into() });
701    }
702
703    #[test]
704    fn the_same_identity_resumes_and_redirects() {
705        let h = head("s2", Some("k1"));
706        assert_eq!(gate("s2", Some(&h), Some("k1"), None, &["s2"], &[]), ResumeGate::Allow);
707        assert_eq!(gate("s1", Some(&h), Some("k1"), None, &["s2"], &[]), ResumeGate::Redirect { head: "s2".into() });
708    }
709
710    /// Either side unknown (an older record, an unreadable `.claude.json`):
711    /// no information, so the gate behaves as it did before identities.
712    #[test]
713    fn an_unknown_identity_on_either_side_changes_nothing() {
714        let known = head("s2", Some("k1"));
715        let unknown = head("s2", None);
716        assert_eq!(gate("s2", Some(&known), None, None, &["s2"], &[]), ResumeGate::Allow);
717        assert_eq!(gate("s2", Some(&unknown), Some("k1"), None, &["s2"], &[]), ResumeGate::Allow);
718        assert_eq!(gate("s1", Some(&known), None, None, &["s2"], &[]), ResumeGate::Redirect { head: "s2".into() });
719    }
720
721    // Relocation (spec §4.3): the head under another login of the same identity.
722
723    fn elsewhere(sid: &str, identity: Option<&str>) -> Head {
724        Head {
725            session_id: sid.into(),
726            identity_key: identity.map(str::to_string),
727            config_dir: Some("C:/cfg/old".into()),
728            provider: "claude".into(),
729            cwd: "C:/work".into(),
730            provider_bytes_end: None,
731        }
732    }
733
734    /// A rebuild: new login, same person. The pane holds the head, whose file
735    /// is only under the old login.
736    #[test]
737    fn the_head_under_another_login_of_the_same_identity_is_relocated() {
738        let h = elsewhere("s2", Some("k1"));
739        let relocate = ResumeGate::Relocate { head: "s2".into(), from_config_dir: "C:/cfg/old".into() };
740        assert_eq!(gate("s2", Some(&h), Some("k1"), None, &[], &["s2"]), relocate);
741        assert_eq!(gate("s1", Some(&h), Some("k1"), None, &["s1"], &["s2"]), relocate, "a stale candidate too");
742    }
743
744    #[test]
745    fn relocation_needs_every_precondition() {
746        let base = elsewhere("s2", Some("k1"));
747        let not_relocated = |h: &Head, identity: Option<&str>, elsewhere: &[&str]| {
748            !matches!(gate("s1", Some(h), identity, None, &[], elsewhere), ResumeGate::Relocate { .. })
749        };
750        assert!(not_relocated(&base, Some("k1"), &[]), "no complete source file");
751        assert!(not_relocated(&base, None, &["s2"]), "spawn identity unknown");
752        assert!(not_relocated(&Head { identity_key: None, ..base.clone() }, Some("k1"), &["s2"]), "head identity unknown");
753        assert!(not_relocated(&Head { provider: "codex".into(), ..base.clone() }, Some("k1"), &["s2"]), "not Claude");
754        assert!(not_relocated(&Head { config_dir: None, ..base.clone() }, Some("k1"), &["s2"]), "no recorded dir");
755        assert!(not_relocated(&Head { config_dir: Some("c:\\cfg\\new\\".into()), ..base.clone() }, Some("k1"), &["s2"]), "the same dir");
756        assert!(not_relocated(&Head { cwd: "C:/elsewhere".into(), ..base.clone() }, Some("k1"), &["s2"]), "another project");
757        assert_eq!(
758            gate("s1", Some(&Head { identity_key: Some("k2".into()), ..base }), Some("k1"), None, &[], &["s2"]),
759            ResumeGate::Refuse { head: "s2".into() },
760            "another identity is refused outright"
761        );
762    }
763
764    // Continued outside AgentMux (spec §4.4, H4).
765
766    /// The head's file is larger than when its segment ended: something
767    /// outside AgentMux (a terminal `claude --resume`) continued it.
768    #[test]
769    fn a_head_that_grew_outside_agentmux_is_forked() {
770        let h = Head { provider_bytes_end: Some(9), ..head("s2", None) };
771        assert_eq!(gate("s2", Some(&h), None, None, &["s2"], &[]), ResumeGate::Fork { head: "s2".into() });
772        assert_eq!(gate("s1", Some(&h), None, None, &["s2"], &[]), ResumeGate::Fork { head: "s2".into() });
773    }
774
775    #[test]
776    fn a_head_as_agentmux_left_it_resumes_in_place() {
777        let same = Head { provider_bytes_end: Some(10), ..head("s2", None) };
778        let unknown = Head { provider_bytes_end: None, ..head("s2", None) };
779        assert_eq!(gate("s2", Some(&same), None, None, &["s2"], &[]), ResumeGate::Allow);
780        assert_eq!(gate("s2", Some(&unknown), None, None, &["s2"], &[]), ResumeGate::Allow);
781    }
782
783    #[test]
784    fn a_segment_end_records_the_provider_file_size_and_older_ends_still_read() {
785        let fs = FileStore::open_in_memory().unwrap();
786        let a = record_start(&fs, start(1_000, Rung::Fresh)).unwrap();
787        record_session(&fs, UID, &a, "sid-a", 1_001).unwrap();
788        record_end_with_provider_bytes(&fs, UID, &a, EndReason::Exited, None, Some(4_096), 2_000).unwrap();
789        assert_eq!(chain_head(&fs, UID).unwrap().provider_bytes_end, Some(4_096));
790
791        let old = serde_json::json!({"event": "end", "segment_id": "s", "ended_at_ms": 5, "end_reason": "exited", "byte_end": null});
792        match serde_json::from_value::<Event>(old).unwrap() {
793            Event::End { provider_bytes_end, .. } => assert_eq!(provider_bytes_end, None),
794            other => panic!("expected an end event, got {other:?}"),
795        }
796    }
797
798    // A head recorded before identity keys existed (spec §4.1).
799
800    fn login_dir(account: &str, org: &str) -> tempfile::TempDir {
801        let d = tempfile::tempdir().unwrap();
802        std::fs::write(
803            d.path().join(".claude.json"),
804            format!(r#"{{"oauthAccount":{{"accountUuid":"{account}","organizationUuid":"{org}"}}}}"#),
805        )
806        .unwrap();
807        d
808    }
809
810    #[test]
811    fn a_legacy_head_takes_its_identity_from_its_own_config_dir() {
812        let dir = login_dir("acc-1", "org-1");
813        let path = dir.path().to_string_lossy().to_string();
814        let legacy = Head { config_dir: Some(path.clone()), provider: "claude".into(), ..head("s2", None) };
815        let expected = crate::identity::account_email::identity_key_from_oauth_dir("claude", &path);
816        assert!(expected.is_some());
817        assert_eq!(with_legacy_identity(legacy).identity_key, expected);
818    }
819
820    #[test]
821    fn a_recorded_identity_always_wins_and_an_unreadable_dir_stays_unknown() {
822        let dir = login_dir("acc-1", "org-1");
823        let path = dir.path().to_string_lossy().to_string();
824        let recorded = Head { config_dir: Some(path), provider: "claude".into(), ..head("s2", Some("k-recorded")) };
825        assert_eq!(with_legacy_identity(recorded).identity_key.as_deref(), Some("k-recorded"));
826        let gone = Head { config_dir: Some("/no/such/dir".into()), provider: "claude".into(), ..head("s2", None) };
827        assert_eq!(with_legacy_identity(gone).identity_key, None);
828        let no_dir = Head { provider: "claude".into(), ..head("s2", None) };
829        assert_eq!(with_legacy_identity(no_dir).identity_key, None);
830    }
831
832    #[test]
833    fn the_chain_head_is_the_newest_segment_with_a_session() {
834        let fs = FileStore::open_in_memory().unwrap();
835        assert_eq!(chain_head(&fs, UID), None);
836        let a = record_start(&fs, start(1_000, Rung::Fresh)).unwrap();
837        record_session(&fs, UID, &a, "sid-a", 1_001).unwrap();
838        let b = record_start(&fs, Start { identity_key: Some("k1".into()), ..start(2_000, Rung::Fresh) }).unwrap();
839        record_session(&fs, UID, &b, "sid-b", 2_001).unwrap();
840        // A spawn that died before reporting a session doesn't move the head.
841        record_start(&fs, start(3_000, Rung::Fresh)).unwrap();
842        let h = chain_head(&fs, UID).expect("a head");
843        assert_eq!(h.session_id, "sid-b");
844        assert_eq!(h.identity_key.as_deref(), Some("k1"));
845        assert_eq!(h.config_dir.as_deref(), Some("C:/cfg/a"));
846        assert_eq!(h.provider, "claude");
847    }
848
849    /// A record written before `identity_key` existed still reads, as `None`.
850    #[test]
851    fn older_records_without_an_identity_still_read() {
852        let old = serde_json::json!({
853            "event": "start", "segment_id": "s", "agent_uid": UID, "definition_id": null, "provider": "claude",
854            "config_dir": null, "provider_session_id": "sid", "cwd": "", "channel": "stable",
855            "agentmux_version": "0.57.5", "block_id": "b", "zone": null, "byte_start": null,
856            "started_at_ms": 1, "continuity_rung": "native", "predecessor_segment_id": null, "lease_epoch": 2,
857        });
858        match serde_json::from_value::<Event>(old).unwrap() {
859            Event::Start(s) => assert_eq!(s.identity_key, None),
860            other => panic!("expected a start event, got {other:?}"),
861        }
862    }
863
864    #[test]
865    fn a_segment_is_closed_exactly_once() {
866        let fs = FileStore::open_in_memory().unwrap();
867        let id = record_start(&fs, start(1, Rung::Fresh)).unwrap();
868        record_end(&fs, UID, &id, EndReason::Closed, None, 10).unwrap();
869        record_end(&fs, UID, &id, EndReason::Crashed, None, 20).unwrap();
870        let s = &segments(&fs, UID)[0];
871        assert_eq!((s.ended_at_ms, s.end_reason), (Some(10), Some(EndReason::Closed)));
872    }
873
874    #[test]
875    fn agents_have_separate_chains() {
876        let fs = FileStore::open_in_memory().unwrap();
877        record_start(&fs, start(1, Rung::Fresh)).unwrap();
878        let mut other = start(2, Rung::Fresh);
879        other.agent_uid = "uid-2".into();
880        let id = record_start(&fs, other).unwrap();
881        assert_eq!(segments(&fs, UID).len(), 1);
882        let theirs = segments(&fs, "uid-2");
883        assert_eq!(theirs[0].start.segment_id, id);
884        assert_eq!(theirs[0].start.predecessor_segment_id, None, "another agent's segment is not a predecessor");
885    }
886
887    #[test]
888    fn a_corrupt_line_is_skipped() {
889        let fs = FileStore::open_in_memory().unwrap();
890        let id = record_start(&fs, start(1, Rung::Fresh)).unwrap();
891        fs.append_data("agent-uid:uid-1:segments", SEGMENTS_FILE, b"{\"event\":\"start\",\"trunc\n").unwrap();
892        let next = record_start(&fs, start(2, Rung::Native)).unwrap();
893        let all = segments(&fs, UID);
894        assert_eq!(all.len(), 2);
895        assert_eq!(all[1].start.segment_id, next);
896        assert_eq!(all[1].start.predecessor_segment_id.as_deref(), Some(id.as_str()));
897    }
898
899    #[test]
900    fn an_unsafe_uid_is_refused() {
901        let fs = FileStore::open_in_memory().unwrap();
902        let mut bad = start(1, Rung::Fresh);
903        bad.agent_uid = "../x:y".into();
904        assert!(record_start(&fs, bad).is_err());
905        assert!(segments(&fs, "../x:y").is_empty());
906    }
907
908    #[test]
909    fn events_are_tagged_json_lines() {
910        let e = Event::End { segment_id: "s".into(), ended_at_ms: 5, end_reason: EndReason::Restarted, byte_end: None, provider_bytes_end: None };
911        let json = serde_json::to_string(&e).unwrap();
912        assert!(json.contains("\"event\":\"end\"") && json.contains("\"end_reason\":\"restarted\""), "{json}");
913    }
914
915    fn seg(id: &str, sid: Option<&str>) -> Segment {
916        let mut s = start(0, Rung::Fresh);
917        s.segment_id = id.into();
918        s.provider_session_id = sid.map(str::to_string);
919        Segment { start: s, ended_at_ms: None, end_reason: None, byte_end: None, provider_bytes_end: None }
920    }
921
922    #[test]
923    fn the_head_is_the_newest_earlier_segment_with_a_session() {
924        let chain = [seg("a", Some("sid-old")), seg("b", Some("sid-head")), seg("c", None), seg("now", Some("sid-attempted"))];
925        assert_eq!(head_session(&chain, Some("now")).as_deref(), Some("sid-head"));
926        assert_eq!(head_session(&chain, None).as_deref(), Some("sid-attempted"));
927        assert_eq!(head_session(&[], Some("now")), None);
928    }
929
930    /// The account-switch case: the head (the old account's session) was
931    /// just rejected, and the new account's config dir holds an older
932    /// session of the same agent. Recovering it would resume the wrong
933    /// conversation.
934    #[test]
935    fn recovery_only_continues_the_head_of_the_chain() {
936        assert!(!recovery_allowed("sid-old", Some("sid-head")));
937        assert!(recovery_allowed("sid-head", Some("sid-head")), "a stale pane id whose real session is the head");
938        assert!(recovery_allowed("sid-any", None), "no known head (pre-index agent): behave as before the index");
939    }
940
941    #[test]
942    fn rungs_and_reasons() {
943        assert_eq!(rung_for_spawn(true, false), Rung::Native);
944        assert_eq!(rung_for_spawn(true, true), Rung::Native, "a resume never carries a packet, but resume wins");
945        assert_eq!(rung_for_spawn(false, true), Rung::Virtualized);
946        assert_eq!(rung_for_spawn(false, false), Rung::Fresh);
947        assert_eq!(kill_reason(true, true), EndReason::Closed);
948        assert_eq!(kill_reason(false, true), EndReason::Restarted);
949        assert_eq!(kill_reason(false, false), EndReason::Stopped);
950        assert_eq!(exit_reason(0), EndReason::Exited);
951        assert_eq!(exit_reason(1), EndReason::Crashed);
952        assert_eq!(exit_reason(-1), EndReason::Crashed);
953    }
954}