Skip to main content

agentmux_srv/backend/blockcontroller/persistent/
eager_resume.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Eager resume: spawning the CLI on controller construction (before the first
5//! message) when a prior session id is on file, so the first turn is not
6//! charged the full cold-start.
7
8use super::*;
9
10impl PersistentSubprocessController {
11    /// Atomically claims the exclusive spawn right `decide_send_action`'s
12    /// `BecomeSpawner` branch also claims, or declines if one is already
13    /// held (`stdin_tx` live, another spawn in flight, or a drain in
14    /// progress). `true` means the caller now owns the claim and MUST
15    /// release it — either directly (a decline with no message ever
16    /// delivered) or via `drain_queue_after_successful_spawn`/
17    /// `respawn_once_for_leftover_queue` (a spawn attempt was made).
18    ///
19    /// Split out of `try_eager_resume` specifically so this state
20    /// transition is unit-testable on its own — codex P1 on PR #3513
21    /// (the spawn-claim race) found that PR's first fix, tested only
22    /// end-to-end, left this exact step's own removal undetected by every
23    /// test in the file (confirmed by mutation: deleting the claim block
24    /// entirely still passed all 6 eager-resume tests, since none of them
25    /// exercised `try_eager_resume`'s OWN claim step in isolation).
26    #[cfg(test)]
27    pub(super) fn try_claim_eager_resume_spawn(&self) -> bool {
28        self.try_claim_eager_resume_spawn_for(None)
29    }
30
31    /// [`Self::try_claim_eager_resume_spawn`], and for a config restart's
32    /// replacement (`Some(generation)`) also require that restart's token
33    /// (`config_restart_generation`) in the SAME acquisition that takes the
34    /// claim, and record the claim as that replacement (`restart_spawn_for`).
35    /// The token is not consumed here: `spawn_process` consumes it right
36    /// before starting the child, so a Stop during the credential gate in
37    /// between still cancels the replacement (codex P1s on #3990).
38    pub(super) fn try_claim_eager_resume_spawn_for(&self, restart_generation: Option<u64>) -> bool {
39        let mut inner = self.inner.lock().unwrap();
40        if inner.stdin_tx.is_some() || inner.spawning_in_progress || inner.drain_claim {
41            return false;
42        }
43        if let Some(generation) = restart_generation {
44            if inner.config_restart_generation != Some(generation) {
45                return false;
46            }
47        }
48        inner.restart_spawn_for = restart_generation;
49        inner.spawning_in_progress = true;
50        true
51    }
52
53    /// `start()`'s eager-resume attempt for a pane with a captured
54    /// `agent:sessionid`. Never returns an error — every failure mode here
55    /// is "fall back to lazy", not "fail the resync"; see `start()`'s own
56    /// doc comment for why.
57    pub(super) fn try_eager_resume(&self, block_meta: &super::super::super::obj::MetaMapType, session_id: &str) -> EagerResumeOutcome {
58        self.try_eager_resume_as(block_meta, session_id, None)
59    }
60
61    /// [`Self::try_eager_resume`], optionally as a config restart's
62    /// replacement: see [`Self::try_claim_eager_resume_spawn_for`].
63    pub(super) fn try_eager_resume_as(
64        &self,
65        block_meta: &super::super::super::obj::MetaMapType,
66        session_id: &str,
67        restart_generation: Option<u64>,
68    ) -> EagerResumeOutcome {
69        let (Some(id_store), Some(identity_store)) = (self.id_store.clone(), self.identity_store.clone()) else {
70            return EagerResumeOutcome::DeclinedTo("identity stores not configured for this controller");
71        };
72        if self.auth_key.is_empty() {
73            return EagerResumeOutcome::DeclinedTo("no auth key configured for this controller");
74        }
75        let Some(mstore) = self.mstore.clone() else {
76            return EagerResumeOutcome::DeclinedTo("no mstore configured for this controller");
77        };
78
79        // `cli_command`/`cli_args`/`working_dir` — same meta keys a live
80        // message's spawn config reads. Deliberately NOT
81        // `agent_handlers::input`'s own print-mode fallback default for a
82        // missing `cmd:args`: that default is defensive for a code path this
83        // eager-resume one never actually reaches in practice
84        // (`agent_open.rs` always seeds `cmd:args` for every persistent
85        // agent at launch time), and guessing the wrong CLI mode for a
86        // persistent agent is worse than just not eager-resuming.
87        let cli_command = crate::backend::obj::meta_get_string(block_meta, "cmd", "claude");
88        let cli_args: Vec<String> = match block_meta.get("cmd:args") {
89            Some(serde_json::Value::Array(arr)) => {
90                arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect()
91            }
92            _ => return EagerResumeOutcome::DeclinedTo("cmd:args meta missing — refusing to guess CLI flags"),
93        };
94        // A pane stored without --model/--effort spawns here, before any message,
95        // and the process never re-reads `cmd:args`: fill from `agent:runtime`.
96        let cli_args = crate::backend::agent_runtime::with_runtime_flags(block_meta, cli_args);
97        let working_dir = crate::backend::obj::meta_get_string(block_meta, "cmd:cwd", "");
98        let base_env_vars = crate::backend::blockcontroller::cmd_env_of(block_meta);
99        let resume_flag = crate::backend::obj::meta_get_string(block_meta, "agent:resume_flag", "--resume");
100        let session_id_field = crate::backend::obj::meta_get_string(block_meta, "agent:session_id_field", "session_id");
101
102        // One live instance per agent (SPEC_AGENT_SINGLE_LIVE_INSTANCE_2026_09_24
103        // Phase 1, I9): if another AgentMux instance on this host runs this
104        // agent, do not resume it here — and decline BEFORE the credential
105        // gate below, which writes shared identity state. In the 2026-09-25
106        // incident this exact path tried to `--resume` the other instance's
107        // live session. Only with a lease store (every production
108        // controller); `block_in_place` needs the multi-thread runtime that
109        // production, unlike these controllers' unit tests, always has.
110        if self.lease_store.is_some() {
111            let uid = crate::backend::obj::meta_get_string(block_meta, "agentId", "");
112            let name = crate::backend::obj::meta_get_string(block_meta, "agentName", "");
113            let admission = tokio::task::block_in_place(|| {
114                tokio::runtime::Handle::current().block_on(self.check_admission(&uid, &name))
115            });
116            if let Err(why) = admission {
117                tracing::warn!(
118                    block_id = %self.block_id,
119                    uid = %uid,
120                    why = %why,
121                    "eager-resume declined: agent is live in another AgentMux instance on this host"
122                );
123                return EagerResumeOutcome::DeclinedTo("agent is live in another AgentMux instance on this host");
124            }
125        }
126        // Identity gate, MuxBus token, reserved wrapper vars (unconditional
127        // overwrite — AGENTMUX_AUTH_KEY/BLOCKID), agent identity, git
128        // identity, tools PATH: the SAME function a live message send
129        // builds its env with (`agent_handlers::input::
130        // build_persistent_spawn_env`), not a second, independent copy.
131        // codex P1 on PR #3513 found an earlier revision of this method had
132        // built its own partial copy that silently drifted: missing PATH/
133        // MuxBus injection entirely, and `entry().or_insert()` instead of
134        // an unconditional overwrite for the two reserved variables (so a
135        // stale persisted value for either would have survived an eager
136        // resume that a live message send would have corrected).
137        //
138        // Claim exclusivity BEFORE the (possibly slow) identity/env
139        // resolution below, not just before the eventual `spawn_process`
140        // call — codex P1 on PR #3513: the controller is placed in the
141        // global registry before `start()` runs (`resync_controller`'s
142        // "Create new controller" branch), so a concurrent message can
143        // reach `send_message` -> `decide_send_action` while this method is
144        // still resolving the identity gate. Without a claim,
145        // `decide_send_action` sees `stdin_tx: None` and
146        // `spawning_in_progress: false` and becomes ITS OWN spawner —
147        // two processes launched against the same `--resume <sid>`, one
148        // silently overwriting the other's `current_pid`/`stdin_tx`/
149        // `kill_tx` while the first stays alive on the same conversation.
150        // Same exclusivity flag `decide_send_action`'s `BecomeSpawner`
151        // branch claims for a live message — this is the same lock a
152        // concurrent `send_message` acquires, so the check-and-set below is
153        // atomic with respect to it.
154        if !self.try_claim_eager_resume_spawn_for(restart_generation) {
155            return EagerResumeOutcome::DeclinedTo(
156                "already running, another spawn already in flight, or a stop overrode the restart \
157                 — nothing to eagerly resume",
158            );
159        }
160
161        // `block_in_place` + `block_on`, not `.await` — `start()` is a sync
162        // trait method (shared by every non-async controller type), but its
163        // CALLER is not: `resync_controller` (this method's only path in)
164        // is invoked synchronously and inline from inside
165        // `Box::pin(async move { ... })` handler bodies (`websocket.rs`'s
166        // `COMMAND_CONTROLLER_RESYNC`) and from `async fn open_agent_impl`
167        // (`agent_open.rs`, both call sites). An earlier revision of this
168        // comment argued sync-fn-therefore-safe-to-block and reagent P1 on
169        // PR #3513 correctly rejected that: `build_persistent_spawn_env`
170        // does a real synchronous-equivalent SQLite query plus, for
171        // `SecretRef::Keychain` accounts, a blocking D-Bus/keyring read
172        // (async only via `spawn_blocking`/`.await` internally) — run on a
173        // bare `.await`-less call from here, that starves the tokio worker
174        // it lands on for every unrelated task queued behind it, exactly
175        // the failure class already named and fixed once in this codebase
176        // (`sysinfo.rs`, `identity_auth_spawn.rs`, `websocket.rs`'s own
177        // `COMMAND_CONTROLLER_INPUT` handler — see the latter's comment
178        // citing incident #1782). Worse here than a one-off: this runs once
179        // per persistent pane on `resync_controller`, i.e. potentially many
180        // panes at once, on exactly the mass-reconnect-after-restart
181        // scenario this whole PR exists to improve. `block_in_place` hands
182        // this worker's other queued tasks off to the pool for the
183        // duration; `Handle::current().block_on` then drives the async
184        // function to completion on this now-isolated thread.
185        let block_id = self.block_id.clone();
186        let auth_key = self.auth_key.clone();
187        let gate_result = tokio::task::block_in_place(|| {
188            tokio::runtime::Handle::current().block_on(
189                crate::server::agent_handlers::input::build_persistent_spawn_env(
190                    mstore,
191                    id_store,
192                    identity_store,
193                    self.broker.clone(),
194                    block_meta,
195                    &block_id,
196                    &auth_key,
197                    base_env_vars,
198                ),
199            )
200        });
201        let env_vars = match gate_result {
202            Ok(env) => env,
203            Err(gate_err) => {
204                tracing::warn!(
205                    block_id = %self.block_id,
206                    error = %gate_err,
207                    "eager-resume declined: identity/credential spawn gate did not pass — \
208                     falling back to lazy (spawns on next message, gated the same way then)"
209                );
210                // Release the claim taken above, and — if anything queued
211                // behind it while the gate ran — tell the operator, because
212                // those prompts are NOT about to run.
213                //
214                // codex P1 + reagent P1 on PR #3538, found independently.
215                // Both proposed handing off to
216                // `respawn_once_for_leftover_queue`, as every other
217                // claim-release site does. That is not available here, for
218                // two reasons, and the second is the important one:
219                //
220                //   1. There is no spawn config yet — `env_vars` is what the
221                //      gate failed to produce, and `config` is built below.
222                //   2. More fundamentally, the gate declining means this
223                //      agent's credentials were REFUSED (deleted/revoked
224                //      account — `SPEC_ACCOUNT_DELETE_DEAUTH_LAYERS_2_4_2026_07_14.md`
225                //      layer 3). Respawning would spawn the process the gate
226                //      just denied, on whatever ambient credential happens to
227                //      be around. That is the exact vulnerability this whole
228                //      gate exists to close, so "deliver the queued prompt"
229                //      must lose to "do not spawn a deauthed agent".
230                //
231                // So the queue is deliberately left in place rather than
232                // drained or dropped: a later send re-runs the gate and, once
233                // credentials are fixed, drains the WHOLE backlog including
234                // these (`drain_queue_after_successful_spawn` doesn't clear
235                // it first). Still gated, never bypassed.
236                //
237                // What WAS wrong is that this was silent. `send_message`
238                // already returned success and may have emitted
239                // `agent-message-accepted`, so without this the operator sees
240                // an accepted prompt that simply never runs, with no reason
241                // given. `flush_error_line_now` gives it the same
242                // classify/persist/publish treatment every other terminal
243                // failure gets, so it surfaces in the pane's failure-recovery
244                // UI instead of vanishing.
245                // The backlog is retained for the next, properly-gated
246                // spawner. Its replay ORDER, should that resume also turn
247                // out stale, is not this branch's problem: the retry batch
248                // is kept in seq (acceptance) order by
249                // `persistent_resume::RetryPayload::append_in_seq_order`,
250                // so these older prompts replay ahead of whichever newer
251                // prompt ends up seeding that spawn (codex P2 on PR #3551).
252                let stranded = {
253                    let mut inner = self.inner.lock().unwrap();
254                    inner.spawning_in_progress = false;
255                    inner.pending_send_messages.len()
256                };
257                if stranded > 0 {
258                    let frame = serde_json::json!({
259                        "type": "result",
260                        "is_error": true,
261                        "subtype": "error_during_execution",
262                        "error": {
263                            "message": format!(
264                                "[AgentMux] {stranded} queued prompt(s) are waiting: this agent's \
265                                 credentials were refused, so it was not resumed. {gate_err}"
266                            )
267                        }
268                    });
269                    self.flush_error_line_now(format!("{frame}\n"));
270                }
271                return EagerResumeOutcome::DeclinedTo("identity/credential spawn gate did not pass");
272            }
273        };
274
275        let config = PersistentSpawnConfig {
276            cli_command,
277            cli_args,
278            working_dir,
279            env_vars,
280            session_id_field,
281            resume_flag,
282            session_id: session_id.to_string(),
283            message_id: None,
284        };
285        let retry_config = config.clone();
286        match self.spawn_process(config, None) {
287            Ok(()) => {
288                // Releases the claim and delivers anything that queued
289                // during the gate's wait above — same mechanism
290                // `respawn_once_for_leftover_queue` uses for the identical
291                // "spawned with nothing of our own, but the queue might not
292                // be empty" shape. `mark_turn_active_and_publish()` (the
293                // PUBLISH side of the flag `spawn_process`'s own internal
294                // check already set the RAW side of) only if something is
295                // actually about to be delivered — unconditionally calling
296                // it, like that other caller does, would reintroduce the
297                // exact "perpetually WORKING with nothing queued" bug this
298                // PR fixes for the far more common empty-queue case.
299                //
300                // reagent P1 on PR #3523: that emptiness check and the drain
301                // used to be two separate lock acquisitions. A send landing
302                // in the gap is routed to `Queued` (the claim is still held),
303                // and `Queued` deliberately does NOT publish turn-active —
304                // the contract is that the spawn-claim holder does it.
305                // Neither does the drain loop. So the message was delivered
306                // and the turn genuinely ran while the pane, Swarm view and
307                // subagent watcher all reported idle.
308                //
309                // Every other spawner publishes unconditionally before
310                // draining, so only eager resume had the gap: it is the one
311                // spawner that routinely has nothing of its own to deliver,
312                // which is why the publish is conditional at all.
313                //
314                // Both are closed by deciding under ONE acquisition of the
315                // same lock `decide_send_action` decides under, so a
316                // concurrent sender is strictly before us (we observe its
317                // message, publish once, and drain it) or strictly after (it
318                // observes a released claim plus a live `stdin_tx`, takes
319                // `DeliverDirect`, and publishes turn-active itself).
320                let has_queued_work = {
321                    let mut inner = self.inner.lock().unwrap();
322                    if inner.pending_send_messages.is_empty() {
323                        // Nothing to drain, so release the claim here rather
324                        // than spawning a drain task whose only job would be
325                        // to release it one hop later — that hop WAS the
326                        // window.
327                        inner.spawning_in_progress = false;
328                        false
329                    } else {
330                        true
331                    }
332                };
333                if has_queued_work {
334                    self.mark_turn_active_and_publish();
335                    // `true`, not `false` — reagent P2 on PR #3538. The spawn
336                    // can succeed at the OS level and the process still exit
337                    // before this drain ever obtains `stdin_tx`; a stale
338                    // `--resume` that dies immediately is exactly that shape,
339                    // and is the case eager resume is most likely to hit. The
340                    // drain's stalled branch then only publishes status and
341                    // releases the claim unless fallback is allowed, leaving
342                    // messages that queued during the identity-gate wait —
343                    // already reported accepted — waiting on an unrelated
344                    // future send.
345                    //
346                    // `false` exists for `respawn_once_for_leftover_queue`'s
347                    // own recursive call, where it bounds the retry to one
348                    // hop so a second stall cannot cascade. Every ordinary
349                    // entry point passes `true` (see
350                    // `release_spawn_claim_and_drain_queue`); this is an
351                    // ordinary entry point and was the odd one out.
352                    //
353                    // Safe because `respawn_once_for_leftover_queue` clears
354                    // `session_id` before respawning, so the fallback starts
355                    // a fresh process rather than re-attempting the
356                    // `--resume` that just died. It also re-enters
357                    // `spawn_process`, which re-runs nothing credential-
358                    // related — the env was already resolved through the
359                    // identity gate for THIS spawn, so the fallback cannot
360                    // bypass it.
361                    self.drain_queue_after_successful_spawn(retry_config, true);
362                }
363                EagerResumeOutcome::Spawned
364            }
365            Err(e) => {
366                tracing::warn!(
367                    block_id = %self.block_id,
368                    error = %e,
369                    "eager-resume declined: spawn failed — falling back to lazy"
370                );
371                self.settle_eager_spawn_failure(&e, retry_config);
372                EagerResumeOutcome::DeclinedTo("spawn failed")
373            }
374        }
375    }
376}
377
378impl PersistentSubprocessController {
379    /// `try_eager_resume`'s spawn-failure settlement, split out so the
380    /// decision is unit-testable without a real child process. Also the
381    /// settlement for a refused *candidate* respawn in
382    /// `respawn_once_for_leftover_queue` (codex P1 on PR #3551, sixth
383    /// round), which fails the same way for the same reason.
384    ///
385    /// Mirrors `release_spawn_claim_and_drain_queue`'s `!spawn_succeeded`
386    /// branch: if something queued during the attempt it needs a live
387    /// process to go to, so hand off to the fallback respawn; otherwise just
388    /// release the claim — there's nothing to drain. Decided under ONE lock
389    /// acquisition, same as the success branch and for the same reason
390    /// (codex P2 on PR #3523): as two, a send arriving in the gap saw the
391    /// claim still held, queued its prompt and returned success, and the
392    /// release then never rechecked — stranding an accepted prompt until
393    /// some unrelated later send happened to become the next spawner.
394    ///
395    /// One failure class is exempt from the fallback (codex P1 on PR
396    /// #3551): `session_held_elsewhere` refusing the `--resume` because the
397    /// same conversation is open — or still closing — in another pane. A
398    /// fresh spawn there would deliver the accepted prompt to a blank
399    /// conversation, bypassing the very guard `spawn_process` just applied.
400    /// Instead: release the claim, DISCARD the queue, keep the session id,
401    /// and tell the user which prompts were not delivered and why.
402    ///
403    /// Discarded, not retained (codex P1 on PR #3551, third round): keeping
404    /// the prompts queued — the credential-gate decline's shape — only
405    /// defers the bypass. The next send would become the spawner, hit the
406    /// same refusal, and `release_spawn_claim_and_drain_queue`'s generic
407    /// failed-spawn path, which knows nothing about WHY, would see the
408    /// leftovers and hand them to `respawn_once_for_leftover_queue` — a
409    /// fresh, blank conversation after all. The only way to make that path
410    /// unable to misread them is for them not to be there.
411    pub(super) fn settle_eager_spawn_failure(&self, err: &str, retry_config: PersistentSpawnConfig) {
412        let ownership_refusal = is_held_elsewhere_error(err);
413        // A Stop that cancelled a config restart's replacement must win over
414        // prompts queued behind it too: the fallback respawn below would start
415        // the agent the user just stopped (codex P1 on #3990).
416        let restart_cancelled = err == RESTART_OVERRIDDEN;
417        let no_fallback = ownership_refusal || restart_cancelled;
418        let (stranded, discarded) = {
419            let mut inner = self.inner.lock().unwrap();
420            let n = inner.pending_send_messages.len();
421            let mut discarded = 0;
422            if n == 0 || no_fallback {
423                inner.spawning_in_progress = false;
424            }
425            if no_fallback {
426                discarded = inner.pending_send_messages.len();
427                inner.pending_send_messages.clear();
428            }
429            (n, discarded)
430        };
431        if stranded == 0 {
432            return;
433        }
434        if !no_fallback {
435            // Claim still held: `respawn_once_for_leftover_queue` owns its
436            // release on either outcome.
437            self.respawn_once_for_leftover_queue(retry_config);
438            return;
439        }
440        let why = if restart_cancelled {
441            "this agent was stopped before its config restart finished, so it was not restarted".to_string()
442        } else {
443            format!("this agent was not resumed. {err}")
444        };
445        let frame = serde_json::json!({
446            "type": "result",
447            "is_error": true,
448            "subtype": "error_during_execution",
449            "error": {
450                "message": format!(
451                    "[AgentMux] {discarded} queued prompt(s) were not delivered and have been discarded: {why}"
452                )
453            }
454        });
455        self.flush_error_line_now(format!("{frame}\n"));
456    }
457}
458
459impl PersistentSubprocessController {
460    /// Called under `inner` just before `spawn_process` starts the OS child:
461    /// if this spawn is a config restart's replacement and a Stop cleared its
462    /// token since the claim (the claim precedes the credential gate, which
463    /// can be slow), refuse, so no process is started at all. A cheap early
464    /// out only: the token stays live, because a Stop can still land while the
465    /// child is being started, and only [`Self::commit_restart_spawn_locked`],
466    /// in the acquisition that installs the child, decides (codex P1s on
467    /// #3990). Refusing returns an error, so the caller takes its ordinary
468    /// spawn-failure path: the claim is released, and a message a human typed
469    /// meanwhile still gets its own spawn.
470    pub(super) fn restart_spawn_still_permitted_locked(inner: &mut PersistentInner) -> Result<(), String> {
471        let Some(generation) = inner.restart_spawn_for else {
472            return Ok(());
473        };
474        if inner.config_restart_generation != Some(generation) {
475            inner.restart_spawn_for = None;
476            return Err(RESTART_OVERRIDDEN.to_string());
477        }
478        Ok(())
479    }
480
481    /// Called in the SAME `inner` acquisition that installs a freshly started
482    /// child as the live process: consume a config restart's token, or refuse
483    /// if a Stop cleared it while the child was starting. Until this point a
484    /// Stop has no `kill_tx` to reach the new process through, so this is
485    /// where it must win: on refusal the caller kills the child it just
486    /// started and fails the spawn.
487    pub(super) fn commit_restart_spawn_locked(inner: &mut PersistentInner) -> Result<(), String> {
488        let Some(generation) = inner.restart_spawn_for.take() else {
489            return Ok(());
490        };
491        if inner.config_restart_generation != Some(generation) {
492            return Err(RESTART_OVERRIDDEN.to_string());
493        }
494        inner.config_restart_generation = None;
495        Ok(())
496    }
497
498    /// Whether the process of `generation` just ended as a committed config
499    /// restart that nothing has overridden, so its exit handler must bring the
500    /// replacement up. Checked by BOTH exit arms: the process can exit on its
501    /// own right after its last `result` and win the `select!` before the kill
502    /// is consumed (codex P1 on #3990). A read, not a take: the token is
503    /// consumed only right before the replacement's child starts, so a Stop
504    /// until then still wins.
505    pub(super) fn config_restart_due_locked(inner: &PersistentInner, generation: u64) -> bool {
506        inner.spawn_generation == generation && inner.config_restart_generation == Some(generation)
507    }
508
509    /// The second half of a deferred runtime-config restart, run by the old
510    /// process's exit handler once it is gone: resume the same session with the
511    /// config now in block meta, exactly as a restart that found the pane idle
512    /// does (`resync_controller` replaces the controller and `start()`
513    /// eager-resumes). Before this the pane stayed empty "until the next
514    /// message", and a jekt is never that message: automated delivery doesn't
515    /// spawn, and it waits while `restart_pending` is set, so every message to
516    /// the agent queued until a human typed into it
517    /// (`docs/retro/RETRO_DEFERRED_RESTART_NEVER_RESPAWNS_2026_09_29.md`).
518    ///
519    /// Nothing is started for a controller that's no longer this block's (a
520    /// pane closed or replaced in the window), when something already replaced
521    /// the process (any spawn clears the token), or when a Stop overrode the
522    /// restart: the token is consumed only inside the spawn claim, under the
523    /// lock a Stop clears it with. Whenever no replacement comes, the restart
524    /// window is closed anyway, so queued deliveries fall to the no-process
525    /// rules (reported stranded after the watchdog's grace) instead of waiting
526    /// for a spawn that isn't coming.
527    pub(super) fn respawn_after_config_restart(&self, generation: u64) {
528        let is_this_blocks_controller = super::super::get_controller(&self.block_id).is_some_and(|c| {
529            c.as_any()
530                .downcast_ref::<PersistentSubprocessController>()
531                .is_some_and(|c| std::ptr::eq(c, self))
532        });
533        if !is_this_blocks_controller {
534            tracing::info!(
535                block_id = %self.block_id,
536                "config restart: controller no longer serves this block — not respawning"
537            );
538            return;
539        }
540        {
541            let inner = self.inner.lock().unwrap();
542            if inner.stdin_tx.is_some() || inner.spawning_in_progress {
543                return;
544            }
545        }
546        let block_meta = self
547            .mstore
548            .as_ref()
549            .and_then(|s| s.get::<crate::backend::obj::Block>(&self.block_id).ok().flatten())
550            .map(|b| b.meta);
551        let session_id = block_meta
552            .as_ref()
553            .map(|m| crate::backend::obj::meta_get_string(m, core::META_SESSION_ID, ""))
554            .unwrap_or_default();
555        let outcome = match block_meta {
556            Some(ref meta) if !session_id.is_empty() => self.try_eager_resume_as(meta, &session_id, Some(generation)),
557            Some(_) => EagerResumeOutcome::DeclinedTo("no session id on file"),
558            None => EagerResumeOutcome::DeclinedTo("block not found"),
559        };
560        match outcome {
561            EagerResumeOutcome::Spawned => {
562                tracing::info!(
563                    block_id = %self.block_id,
564                    session_id = %session_id,
565                    "config restart: replacement process resumed the session"
566                );
567            }
568            EagerResumeOutcome::DeclinedTo(reason) => {
569                self.close_restart_window_without_process();
570                tracing::warn!(
571                    block_id = %self.block_id,
572                    reason = %reason,
573                    "config restart: could not resume a replacement — the agent waits for its next \
574                     message, and queued deliveries follow the no-process rules"
575                );
576            }
577        }
578    }
579
580    /// End a config restart's quiesce window when no replacement is coming:
581    /// with `restart_pending` (and the restart's own `stop_pending`) left set,
582    /// `deferred_must_wait_locked` holds every queued delivery forever and the
583    /// watchdog never reports it. Leaves them alone if a spawn has meanwhile
584    /// begun, since that spawn clears both itself.
585    pub(super) fn close_restart_window_without_process(&self) {
586        let mut inner = self.inner.lock().unwrap();
587        if inner.stdin_tx.is_none() && !inner.spawning_in_progress {
588            inner.config_restart_generation = None;
589            inner.restart_spawn_for = None;
590            inner.restart_pending = false;
591            inner.stop_pending = false;
592        }
593    }
594}
595
596/// Why a config restart's replacement was abandoned: a Stop landed first.
597pub(super) const RESTART_OVERRIDDEN: &str = "a stop overrode the config restart before its replacement started";