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";