From d1f551ea57056e79fb06233afe6ac46f3b574c50 Mon Sep 17 00:00:00 2001 From: Nikhil Unni Date: Tue, 10 Nov 2026 13:03:11 -0800 Subject: [PATCH] coordinator: an earlier nomination's evict op does not disable the park The eviction scanner reads a session's latest evict op before it mints one. A Done op older than the settle grace meant 'the capture landed and the settle did not', so the scanner minted a capture retry with allow_park false. The read has no nomination scope: a session that parked, came back, and went idle again still holds the earlier Done op. The idle detector moves the session to Evicting and then queues its op. When a scanner pass reads the session between those two writes, it saw the earlier op, minted the capture retry, and the session took a full eviction where it had room to park. Both drivers tick on the same period from the same start, so the overlap is not rare. The scanner now applies the Done rule only to an op that finished at or after the nomination's stamp (last_active_at). An earlier op leaves allow_park true. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01HNhpmz4QPYaVCE7tFna54D --- crates/engram-coordinator/src/idle_evictor.rs | 60 ++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/crates/engram-coordinator/src/idle_evictor.rs b/crates/engram-coordinator/src/idle_evictor.rs index 9e6c2617c..6d275003f 100644 --- a/crates/engram-coordinator/src/idle_evictor.rs +++ b/crates/engram-coordinator/src/idle_evictor.rs @@ -1286,7 +1286,14 @@ async fn scanner_advance_one( engram_core::types::session_op::OpState::Queued => { return Ok(()); } - engram_core::types::session_op::OpState::Done => { + // An op that finished before this nomination began (a park, or + // an eviction the session has since returned from) says nothing + // about this one. `last_active_at` is the nomination's stamp. + engram_core::types::session_op::OpState::Done + if prior + .finished_at + .is_some_and(|t| t >= session.last_active_at) => + { let within_grace = prior.finished_at.is_some_and(|t| { state .services @@ -3479,6 +3486,57 @@ mod tests { ); } + /// A session that parked or evicted, came back, and is nominated again + /// still holds the earlier nomination's Done evict op. The scanner can + /// see the new nomination before the detector queues its op; the op the + /// scanner then mints must allow the park. + #[tokio::test] + async fn an_earlier_nominations_done_op_does_not_disable_the_park() { + let session_id = engram_core::SessionId::new(); + let session = evicting_session(session_id); + let earlier_nomination = session.last_active_at; + let sandbox_root = TempDir::new().unwrap(); + let (state, meta, _stashed) = d5_state(session, sandbox_root.path()); + let sandbox_id = state.services.host.create(process_spec()).await.unwrap(); + state + .services + .meta + .fenced_assign_sandbox(session_id, 0, Some(sandbox_id), None) + .await + .unwrap(); + let key = format!("evict:{}", earlier_nomination.timestamp_millis()); + let op = match state + .services + .meta + .op_enqueue_and_claim( + session_id, + OpKind::Evict, + serde_json::json!({ "target": "idle", "allow_park": true, "nominated": true }), + Some(&key), + "test-pod", + ) + .await + .expect("enqueue+claim") + { + EnqueueOutcome::Claimed(op) => op, + other => panic!("op lane busy: {other:?}"), + }; + crate::session_ops::drive_claimed(&state, op).await; + + // The new nomination begins after that op finished. + { + let mut session = meta.session.lock(); + session.status = SessionState::Evicting; + session.last_active_at = chrono::Utc::now() + chrono::Duration::seconds(1); + } + let ops_before = meta.ops.all().len(); + scanner_run_once(&state).await.expect("tick"); + let ops = meta.ops.all(); + assert_eq!(ops.len(), ops_before + 1, "the new nomination gets its op"); + let minted = ops.iter().max_by_key(|op| op.id).unwrap(); + assert_eq!(minted.payload["allow_park"], true, "{:?}", minted.payload); + } + /// ADR 0101 C (engrams review, #836 round 3): the suppression must be /// KEY-AGNOSTIC — the admin/evict_local path mints its evict op with /// NO idempotency key, and the park-descent path mints under