Skip to main content

agentmux_srv/backend/blockcontroller/persistent/
segments.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! This controller's side of the segment index
5//! (`backend::continuity_segments`): each spawn is one segment of its
6//! agent's conversation. Best-effort throughout: a failed write is logged and
7//! never affects the process.
8
9use super::*;
10use crate::backend::continuity_segments as segs;
11
12/// The segment a spawned process belongs to: `(agent_uid, segment_id)`.
13pub(super) type SegmentRef = Option<(String, String)>;
14
15/// The config dir the spawn gives its provider: the provider's own
16/// `auth_config_dir_env_var` (Gemini's is `GEMINI_CLI_HOME`, not the
17/// `GEMINI_CONFIG_DIR` a hand-kept list here once named), else the first
18/// known provider's variable the env carries.
19fn spawn_config_dir(provider: &str, env: &std::collections::HashMap<String, String>) -> Option<String> {
20    if let Some(p) = crate::backend::providers::get_provider(provider) {
21        if let Some(v) = env.get(p.auth_config_dir_env_var) {
22            return Some(v.clone());
23        }
24    }
25    crate::backend::providers::all_providers().find_map(|p| env.get(p.auth_config_dir_env_var).cloned())
26}
27
28use agentmux_common::time::now_ms;
29
30/// Size of `output` in the global zone, read from the database rather than
31/// this process's cache, so another channel's appends count.
32fn zone_size(zone: &str) -> Option<i64> {
33    let gfs = crate::backend::agent_session::global_transcript_store()?;
34    gfs.line_state(zone, crate::backend::agent_session::OUTPUT_FILE).ok()?.map(|s| s.size)
35}
36
37impl PersistentSubprocessController {
38    /// Claims this agent's single-live-instance lease, then runs the resume
39    /// gate ([`apply_resume_gate_with`](Self::apply_resume_gate_with)) under it.
40    ///
41    /// The lease is one live instance per agent across every AgentMux instance
42    /// on this host (SPEC_AGENT_SINGLE_LIVE_INSTANCE_2026_09_24.md Phase 1),
43    /// held for the spawned process's lifetime. Claiming it FIRST means the
44    /// gate's sweep, relocation and settle (spec §4.3) run for one spawn of an
45    /// agent at a time: two controllers of one agent racing those steps kept
46    /// opening narrower windows (#3924). The session is only a hint to the
47    /// lease — recorded, not a key — so the gate settling the final id
48    /// afterwards changes nothing about the claim.
49    ///
50    /// When leasing is unavailable (no store, or no agent UID) the gate runs
51    /// unguarded, as it did before Phase 1.
52    pub(super) fn lease_then_gate(
53        &self,
54        config: &PersistentSpawnConfig,
55        gfs: Option<&FileStore>,
56        history_session: Option<String>,
57    ) -> Result<Option<Arc<crate::backend::agent_admission::HeldAgentLease>>, String> {
58        let candidate = if config.resume_flag.is_empty() {
59            None
60        } else {
61            self.inner.lock().unwrap().session_id.clone().or_else(|| history_session.clone())
62        };
63        let lease = match self.acquire_agent_lease(config, candidate.as_deref()) {
64            Ok(lease) => lease,
65            // The agent is open in another pane of this process (it holds the
66            // lease, whatever session either pane holds), or this conversation
67            // is live or still closing there. Report that as the held-elsewhere
68            // refusal it is: callers treat only that one as "don't fall back to
69            // a fresh session" (`is_held_elsewhere_error`), and the wording is
70            // "another pane", not "another instance".
71            Err(e) => {
72                let uid = config.env_vars.get("AGENTMUX_AGENT_UID").map(|u| u.trim()).filter(|u| !u.is_empty());
73                if let Some(other) = uid.and_then(|uid| self.agent_held_by_other_pane(uid)) {
74                    return Err(super::held_elsewhere_error(&other, false));
75                }
76                if let Some((other, closing)) = candidate.as_deref().and_then(|sid| self.session_held_elsewhere(sid)) {
77                    return Err(super::held_elsewhere_error(&other, closing));
78                }
79                return Err(e);
80            }
81        };
82        self.apply_resume_gate_with(config, gfs, history_session);
83        Ok(lease)
84    }
85
86    /// The one resume gate
87    /// (SPEC_RESUME_GATE_AND_SAME_IDENTITY_CONTINUATION_2026_09_25.md §4.2):
88    /// before this spawn passes `--resume`, only the head of the agent's
89    /// segment chain may be the session it resumes. A stale id — the
90    /// conversation moved on in another channel, build or pane — is
91    /// redirected to the head when this config dir reaches it, and cleared
92    /// otherwise, so the spawn goes fresh and carries AgentMux's record.
93    /// Runs after every path that sets the id (hydrate from config, first
94    /// spawn onto rendered history) and under the agent's lease
95    /// ([`lease_then_gate`](Self::lease_then_gate)), before the held-elsewhere
96    /// check, which then applies to whatever id survives. Never fails the
97    /// spawn: anything unknown leaves the id as it was.
98    ///
99    /// `history_session`: on a first spawn holding no id, the session of the
100    /// history the pane renders when `--resume` can't reach it here. It is a
101    /// candidate for relocation only (a rebuild onto a new login of the same
102    /// identity); any other decision leaves the spawn holding no id, as
103    /// before.
104    pub(super) fn apply_resume_gate_with(
105        &self,
106        config: &PersistentSpawnConfig,
107        gfs: Option<&FileStore>,
108        history_session: Option<String>,
109    ) {
110        if config.resume_flag.is_empty() {
111            return;
112        }
113        let (held, poisoned) = {
114            let inner = self.inner.lock().unwrap();
115            (inner.session_id.clone(), inner.resume_poisoned.clone())
116        };
117        let relocate_only = held.is_none();
118        let Some(candidate) = held.or(history_session).filter(|c| poisoned.as_deref() != Some(c.as_str())) else {
119            return;
120        };
121        // Reachability is a file check under the provider's config dir; only
122        // Claude's is known. Without one the head can't be checked, so the
123        // gate stays out of the way.
124        let Some(config_dir) = config.env_vars.get("CLAUDE_CONFIG_DIR") else { return };
125        // A non-Claude spawn can still inherit that variable; its sessions
126        // aren't under it, so checking there would refuse every resume.
127        let provider = self
128            .mstore
129            .as_deref()
130            .and_then(|s| s.get::<crate::backend::obj::Block>(&self.block_id).ok().flatten())
131            .map(|b| crate::backend::obj::meta_get_string(&b.meta, "agentProvider", ""))
132            .unwrap_or_default();
133        if !matches!(provider.as_str(), "" | "claude" | "claude-code") {
134            return;
135        }
136        let (Some(gfs), Some(uid)) = (gfs, config.env_vars.get("AGENTMUX_AGENT_UID").filter(|u| !u.trim().is_empty()))
137        else {
138            return;
139        };
140        let uid = uid.trim();
141        let cwd = config.working_dir.as_str();
142        // A copy this agent relocated earlier and whose fork never reported an
143        // id (the process died) goes first: left in place, it would look
144        // reachable and be resumed in place, a lasting second file with the
145        // head's id (spec §3 I4).
146        let swept = crate::backend::continuity_relocate::sweep(config_dir, cwd, uid);
147        if swept > 0 {
148            tracing::info!(target: "continuity", block_id = %self.block_id, swept, "resume gate: removed relocated copies a dead spawn left");
149        }
150        let head = segs::chain_head(gfs, uid).map(segs::with_legacy_identity);
151        // A fork with no chain of its own starts from the agent it was forked from.
152        let fork_source = if head.is_none() { self.mstore.as_deref().and_then(|s| segs::fork_source_head(s, gfs, uid)) } else { None };
153        let identity = crate::identity::account_email::identity_key_from_oauth_dir("claude", config_dir);
154        let input = segs::GateInput {
155            candidate: &candidate,
156            head: head.as_ref(),
157            identity: identity.as_deref(),
158            poisoned: poisoned.as_deref(),
159            config_dir,
160            cwd,
161            fork_source: fork_source.as_ref(),
162        };
163        let decision = segs::resume_gate(
164            &input,
165            |sid| provider_file_size(config_dir, cwd, sid),
166            crate::backend::continuity_relocate::can_relocate,
167        );
168        // Copy a session file in from where it was recorded, for `--fork-session`.
169        let place = |from_config_dir: &str, from_cwd: &str, head: &str| {
170            crate::backend::continuity_relocate::source_file(from_config_dir, from_cwd, head)
171                .ok_or_else(|| "source gone".to_string())
172                .and_then(|src| crate::backend::continuity_relocate::relocate(&src, config_dir, cwd, head, uid))
173        };
174        let refuse = |head: &str, why: &str| {
175            tracing::info!(
176                target: "continuity",
177                block_id = %self.block_id,
178                candidate = %candidate,
179                head = %head,
180                why,
181                "resume gate: the conversation is in a session this spawn can't resume; starting fresh with the record"
182            );
183            (None, None, false)
184        };
185        if relocate_only && !matches!(decision, segs::ResumeGate::Relocate { .. } | segs::ResumeGate::ForkFrom { .. }) {
186            return;
187        }
188        let (next, fork_copy, fork) = match decision {
189            segs::ResumeGate::Allow => return,
190            segs::ResumeGate::Fork { head } => {
191                tracing::info!(
192                    target: "continuity",
193                    block_id = %self.block_id,
194                    candidate = %candidate,
195                    head = %head,
196                    "resume gate: the session grew after AgentMux last saw it (continued outside AgentMux); forking it"
197                );
198                (Some(head), None, true)
199            }
200            segs::ResumeGate::Redirect { head } => {
201                tracing::info!(
202                    target: "continuity",
203                    block_id = %self.block_id,
204                    candidate = %candidate,
205                    head = %head,
206                    "resume gate: the conversation moved on; resuming the chain head instead"
207                );
208                (Some(head), None, false)
209            }
210            segs::ResumeGate::Refuse { head } => refuse(&head, "out of reach, or another identity's"),
211            segs::ResumeGate::Relocate { head, from_config_dir } => {
212                match place(&from_config_dir, cwd, &head) {
213                    Ok(copy) => {
214                        tracing::info!(
215                            target: "continuity",
216                            block_id = %self.block_id,
217                            candidate = %candidate,
218                            head = %head,
219                            from = %from_config_dir,
220                            "resume gate: the conversation is under another login of the same identity; relocated it to fork from"
221                        );
222                        (Some(head), Some(copy), true)
223                    }
224                    Err(e) => refuse(&head, &format!("relocation failed: {e}")),
225                }
226            }
227            segs::ResumeGate::ForkFrom { head, from_config_dir, from_cwd } => match place(&from_config_dir, &from_cwd, &head) {
228                Ok(copy) => {
229                    tracing::info!(
230                        target: "continuity",
231                        block_id = %self.block_id,
232                        head = %head,
233                        from = %from_config_dir,
234                        "resume gate: a fork's first spawn; copied the conversation it was forked from to fork from"
235                    );
236                    (Some(head), Some(copy), true)
237                }
238                Err(e) => refuse(&head, &format!("copying the conversation it was forked from failed: {e}")),
239            },
240        };
241        {
242            let mut inner = self.inner.lock().unwrap();
243            // Only replace what the gate judged: another path may have moved
244            // the id meanwhile.
245            let judged = if relocate_only { None } else { Some(candidate.as_str()) };
246            if inner.session_id.as_deref() != judged {
247                drop(inner);
248                if let Some(copy) = fork_copy {
249                    crate::backend::continuity_relocate::remove(&copy);
250                }
251                return;
252            }
253            inner.session_id = next.clone();
254            inner.fork_copy = fork_copy;
255            inner.fork_next = fork;
256        }
257        core::persist_session_id(&self.block_id, next.as_deref().unwrap_or(""), &self.mstore, &self.event_bus);
258    }
259
260    /// Reconcile the agent's memory record with the folder this spawn is
261    /// about to use, before the provider starts, within
262    /// [`memory_reconcile::RECONCILE_BUDGET`](crate::backend::memory_reconcile::RECONCILE_BUDGET)
263    /// (SPEC_MEMORY_FOLLOWS_THE_AGENT_2026_09_24.md §2.1.3). Never fails the
264    /// spawn. Off for an agent with `agent:memoryrecord` = `off`.
265    pub(super) fn reconcile_memory_before_spawn(&self, config: &PersistentSpawnConfig) {
266        let (Some(gfs), Some(mstore)) = (crate::backend::agent_session::global_transcript_store(), self.mstore.as_deref()) else {
267            return;
268        };
269        let Some(uid) = config.env_vars.get("AGENTMUX_AGENT_UID").filter(|u| !u.is_empty()) else {
270            return;
271        };
272        let meta = mstore
273            .get::<crate::backend::obj::Block>(&self.block_id)
274            .ok()
275            .flatten()
276            .map(|b| b.meta)
277            .unwrap_or_default();
278        if crate::backend::obj::meta_get_string(&meta, "agent:memoryrecord", "") == "off" {
279            return;
280        }
281        // The same agent live in another pane (a second pane starting fresh
282        // beside it) writes the same folder while the pass would run: leave
283        // it to that agent's next spawn.
284        let live_elsewhere = super::super::get_all_controllers().into_iter().any(|(block_id, ctrl)| {
285            block_id != self.block_id
286                && ctrl.as_any().downcast_ref::<PersistentSubprocessController>().is_some_and(|other| {
287                    other.stable_agent_uid.lock().unwrap().as_deref() == Some(uid.as_str())
288                        && other.inner.lock().unwrap().current_pid.is_some()
289                })
290        });
291        if live_elsewhere {
292            tracing::info!(block_id = %self.block_id, uid, "memory reconcile skipped: the agent is live in another pane");
293            return;
294        }
295        let provider = crate::backend::obj::meta_get_string(&meta, "agentProvider", "");
296        let reconcile = || {
297            crate::backend::memory_reconcile::reconcile_before_spawn(
298                gfs,
299                mstore,
300                &crate::backend::memory_reconcile::SpawnMemory {
301                    uid,
302                    provider: &provider,
303                    config_dir: config.env_vars.get("CLAUDE_CONFIG_DIR").map(String::as_str),
304                    cwd: &config.working_dir,
305                    overridden: crate::backend::memory_reconcile::spawn_overrides_memory_dir(&config.env_vars, &config.cli_args),
306                    history_stores: None,
307                },
308                crate::backend::memory_reconcile::RECONCILE_BUDGET,
309            )
310        };
311        // Up to a second of blocking file and SQLite I/O: on the async
312        // callers' runtime (run_agent_turn, agent.send) hand the worker's
313        // other tasks off first. block_in_place panics on a current-thread
314        // runtime, where there is no other worker to hand them to.
315        let on_multi_thread = tokio::runtime::Handle::try_current()
316            .is_ok_and(|h| h.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread);
317        let report = if on_multi_thread { tokio::task::block_in_place(reconcile) } else { reconcile() };
318        if report.deferred {
319            tracing::info!(block_id = %self.block_id, uid, "memory reconcile deferred: over budget");
320        } else if report != Default::default() {
321            tracing::info!(block_id = %self.block_id, uid, ?report, "memory reconciled before spawn");
322        }
323    }
324
325    /// Records the start of the segment this spawn begins. `None` when the
326    /// spawn carries no agent UID (quick-launch panes, a continuation that
327    /// resumes before its row exists) or the write fails.
328    #[allow(clippy::too_many_arguments)]
329    pub(super) fn record_segment_start(
330        &self,
331        agent_uid: &str,
332        config: &PersistentSpawnConfig,
333        attempted_resume_sid: Option<&str>,
334        forked_from: Option<&str>,
335        relocated: bool,
336        carries_packet: bool,
337        lease_epoch: Option<u64>,
338    ) -> SegmentRef {
339        let gfs = crate::backend::agent_session::global_transcript_store()?;
340        let start = self.segment_start(agent_uid, config, attempted_resume_sid, forked_from, relocated, carries_packet, lease_epoch);
341        let rung = start.continuity_rung;
342        match segs::record_start(gfs, start) {
343            Ok(id) => {
344                tracing::info!(block_id = %self.block_id, segment_id = %id, rung = ?rung, "continuity: segment started");
345                Some((agent_uid.to_string(), id))
346            }
347            Err(e) => {
348                tracing::warn!(block_id = %self.block_id, error = %e, "continuity: segment start not recorded");
349                None
350            }
351        }
352    }
353
354    /// The `Start` event [`record_segment_start`](Self::record_segment_start)
355    /// writes. A relocated fork records no session until it reports its own:
356    /// the attempted id names the copy, which is swept if the process dies
357    /// first, so as the head it would send the next spawn to `--resume` a
358    /// file that is gone instead of relocating again (spec §4.3).
359    #[allow(clippy::too_many_arguments)]
360    pub(super) fn segment_start(
361        &self,
362        agent_uid: &str,
363        config: &PersistentSpawnConfig,
364        attempted_resume_sid: Option<&str>,
365        forked_from: Option<&str>,
366        relocated: bool,
367        carries_packet: bool,
368        lease_epoch: Option<u64>,
369    ) -> segs::Start {
370        let meta = self
371            .mstore
372            .as_deref()
373            .and_then(|s| s.get::<crate::backend::obj::Block>(&self.block_id).ok().flatten())
374            .map(|b| b.meta)
375            .unwrap_or_default();
376        let definition_id = Some(crate::backend::obj::meta_get_string(&meta, "agentId", "")).filter(|d| !d.is_empty());
377        let provider = crate::backend::obj::meta_get_string(&meta, "agentProvider", "");
378        let zone = crate::backend::agent_session::agent_zone_for_block_meta(&meta);
379        let config_dir = spawn_config_dir(&provider, &config.env_vars);
380        let identity_key = config_dir
381            .as_deref()
382            .and_then(|dir| crate::identity::account_email::identity_key_from_oauth_dir(&provider, dir));
383        segs::Start {
384            segment_id: String::new(),
385            agent_uid: agent_uid.to_string(),
386            definition_id,
387            config_dir,
388            identity_key,
389            forked_from: forked_from.map(str::to_string),
390            provider,
391            provider_session_id: attempted_resume_sid.filter(|_| !relocated).map(str::to_string),
392            cwd: config.working_dir.clone(),
393            channel: crate::backend::reactive::registry::local_channel_id(),
394            agentmux_version: crate::backend::base::get_version().to_string(),
395            block_id: self.block_id.clone(),
396            byte_start: zone.as_deref().and_then(zone_size),
397            zone,
398            started_at_ms: now_ms(),
399            continuity_rung: segs::rung_for_spawn(attempted_resume_sid.is_some(), carries_packet),
400            predecessor_segment_id: None,
401            lease_epoch,
402        }
403    }
404}
405
406/// A fresh process reported its provider session id.
407pub(super) fn record_segment_session(segment: &SegmentRef, provider_session_id: &str) {
408    let (Some((uid, id)), Some(gfs)) = (segment, crate::backend::agent_session::global_transcript_store()) else {
409        return;
410    };
411    if let Err(e) = segs::record_session(gfs, uid, id, provider_session_id, now_ms()) {
412        tracing::warn!(segment_id = %id, error = %e, "continuity: segment session not recorded");
413    }
414}
415
416/// The segment's process went away. `zone` is its global transcript zone.
417/// Size of the Claude session file `sid` for `cwd` under `config_dir`;
418/// `None` when it isn't there.
419fn provider_file_size(config_dir: &str, cwd: &str, sid: &str) -> Option<u64> {
420    let path = crate::backend::session_backfill::session_file_path(config_dir, cwd, sid)?;
421    std::fs::metadata(path).ok().filter(|m| m.is_file()).map(|m| m.len())
422}
423
424/// The provider session file's size for segment `id`, from what its own
425/// record says about where it ran (spec §4.4). Claude only.
426fn provider_bytes_for_segment(gfs: &FileStore, uid: &str, id: &str) -> Option<i64> {
427    let segment = segs::segments(gfs, uid).into_iter().find(|s| s.start.segment_id == id)?;
428    let start = segment.start;
429    if !matches!(start.provider.as_str(), "claude" | "claude-code") {
430        return None;
431    }
432    let size = provider_file_size(start.config_dir.as_deref()?, &start.cwd, start.provider_session_id.as_deref()?)?;
433    i64::try_from(size).ok()
434}
435
436pub(super) fn record_segment_end(segment: &SegmentRef, reason: segs::EndReason, zone: Option<&str>) {
437    let (Some((uid, id)), Some(gfs)) = (segment, crate::backend::agent_session::global_transcript_store()) else {
438        return;
439    };
440    let provider_bytes = provider_bytes_for_segment(gfs, uid, id);
441    if let Err(e) = segs::record_end_with_provider_bytes(gfs, uid, id, reason, zone.and_then(zone_size), provider_bytes, now_ms()) {
442        tracing::warn!(segment_id = %id, error = %e, "continuity: segment end not recorded");
443    } else {
444        tracing::info!(segment_id = %id, reason = ?reason, "continuity: segment ended");
445    }
446}
447
448#[cfg(test)]
449mod config_dir_tests {
450    use super::spawn_config_dir;
451
452    fn env(pairs: &[(&str, &str)]) -> std::collections::HashMap<String, String> {
453        pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
454    }
455
456    #[test]
457    fn each_provider_records_its_own_config_dir() {
458        assert_eq!(spawn_config_dir("claude", &env(&[("CLAUDE_CONFIG_DIR", "/c")])).as_deref(), Some("/c"));
459        assert_eq!(spawn_config_dir("gemini", &env(&[("GEMINI_CLI_HOME", "/g")])).as_deref(), Some("/g"));
460        assert_eq!(spawn_config_dir("qwen", &env(&[("QWEN_HOME", "/q")])).as_deref(), Some("/q"));
461        assert_eq!(spawn_config_dir("kimi", &env(&[("KIMI_SHARE_DIR", "/k")])).as_deref(), Some("/k"));
462    }
463
464    #[test]
465    fn the_providers_own_variable_wins_over_another_in_the_env() {
466        let e = env(&[("CLAUDE_CONFIG_DIR", "/c"), ("CODEX_HOME", "/x")]);
467        assert_eq!(spawn_config_dir("codex", &e).as_deref(), Some("/x"));
468        assert_eq!(spawn_config_dir("", &env(&[])), None);
469    }
470}
471
472/// The outcome of a relocated resume (spec §4.3): the fork reports its own
473/// id, which `persistent_resume` reads as the CLI starting fresh. Carrying
474/// the whole conversation into a new id is the resume succeeding.
475pub(super) fn forked_outcome(
476    outcome: persistent_resume::SessionOutcome,
477    forked_from: Option<&str>,
478    attempted_sid: &str,
479    actual_sid: Option<&str>,
480) -> persistent_resume::SessionOutcome {
481    let forked = forked_from == Some(attempted_sid) && actual_sid.is_some_and(|a| a != attempted_sid);
482    if forked {
483        persistent_resume::SessionOutcome::Resumed
484    } else {
485        outcome
486    }
487}
488
489/// A relocated resume the CLI kept under the attempted id, resumed in place
490/// rather than forked. Capture skips an id the controller already holds, so
491/// this is settled on its own, once a result frame proves the resume worked
492/// (an earlier frame echoes the attempted id even when the resume goes on to
493/// fail). Left unsettled, the segment would hold no session (a relocated
494/// Start records none) and the next spawn would sweep the live copy.
495/// `holds_current`: the reader's generation is still current and holds the
496/// id; a superseded one's copy path may already be the replacement's.
497pub(super) fn kept_relocated_id(
498    adopted: bool,
499    confirmed: bool,
500    holds_current: bool,
501    has_copy: bool,
502    forked_from: Option<&str>,
503    captured: &str,
504) -> bool {
505    !adopted && confirmed && holds_current && has_copy && forked_from == Some(captured)
506}
507
508/// Once a relocated resume reports its session: a fork (a new id) never
509/// wrote the copy, so it goes. The same id back means the CLI resumed the
510/// copy in place instead of forking; it is live now, so only its marker
511/// goes, and the duplicate is logged (spec §3 I4). Returns whether the copy
512/// was still this spawn's: a replacement's sweep may have taken it and
513/// placed its own at the same path, which is then left alone.
514pub(super) fn settle_relocated_copy(
515    block_id: &str,
516    copy: &crate::backend::continuity_relocate::Relocated,
517    forked_from: Option<&str>,
518    captured: &str,
519) -> bool {
520    if forked_from == Some(captured) {
521        let owned = crate::backend::continuity_relocate::unmark(copy);
522        if owned {
523            tracing::warn!(
524                target: "continuity",
525                block_id,
526                session_id = captured,
527                "relocated resume did not fork; keeping the copy as the live session"
528            );
529        }
530        owned
531    } else {
532        crate::backend::continuity_relocate::remove(copy)
533    }
534}