Skip to content

bug(compute): periodic reconciliation can persist Error during an explicit start #4932

Open

Description

@prekshivyas

User Story

I use OpenShell-backed agent sandboxes and need reliable stop/start with the same sandbox identity and retained workspace. My Linux validation encountered a successful driver start followed by a persistent gateway Error while the replacement container was running.

Problem Statement

On OpenShell 0.0.116, periodic reconciliation can read an old exited container after start_sandbox has committed Starting but before its driver start completes. It writes Error into that new transition. The successful start returns the poisoned row, and later ready snapshots do not recover it because Error is terminal.

The resource-version check does not prevent this: the sweep can read the already-committed Starting version while the driver operation is still in flight. Snapshot reconciliation and missing-resource pruning do not take the lifecycle gate held by start.

This mechanism is deterministically reproduced against tag v0.0.116, commit d1155aa70042d3e2ee49dbfa15346b108b7c1d92. Follow-up also reproduced the mechanism on current main d0a163a. See the separately scoped candidate, test results and unresolved qualification boundaries at #4932 (comment).

Impact / Why This Matters

A supported stop/start can leave a healthy replacement unusable through the gateway. Waiting longer does not repair a terminal persisted Error. Ignoring Error, rewriting the database, or deleting and recreating the sandbox is not an acceptable data-preserving solution.

This differs from #4828's external Docker restart and #4129's late watch update: the observed writer here is the periodic reconcile.sandboxes sweep during an explicit start. #4115 is a broader lifecycle-coordination proposal.

Acceptance Criteria

  • Periodic reconciliation cannot apply a pre-start exited or temporarily absent backend observation during an owned start operation.
  • After a successful start and valid replacement readiness, the same sandbox reaches Ready.
  • A genuine replacement failure still reaches Error.
  • Unrelated sandbox events and cleanup remain responsive; existing cancellation, stop/start, and cleanup tests pass.
  • Qualify the correction with the affected Docker stop/start E2E path, without suppressing errors or weakening readiness checks.

Reproduction Steps

The deterministic reproduction uses only OpenShell's existing unit-test runtime and controlled driver, with an in-memory SQLite store. It does not need a third-party workload or credentials.

  1. Check out OpenShell v0.0.116 at the commit above.
  2. Add the regression tests from the compute/mod.rs test hunk in the candidate patch below, leaving the production hunks unapplied.
  3. Run cargo test --locked -p openshell-server --lib --features bundled-z3 waits_for_start_before_reading_backend -- --nocapture.
  4. All three tests fail: the successful start returns Error (3), not Starting (8).
  5. Apply the production hunks, then run cargo test --locked -p openshell-server --lib --features bundled-z3 compute::tests::.

The tests pause the real start path after its durable transition, force a concurrent sweep to observe an old exit or a missing backend, replace that observation with the ready replacement, then release start. They require Ready afterward and still require Error for a subsequent genuine replacement exit.

Environment

  • Observed failure: OpenShell CLI/gateway/sandbox 0.0.116; Ubuntu 24.04.5 amd64; Docker compute driver, single gateway.
  • Deterministic tests: exact 0.0.116 source, Linux aarch64, Rust 1.95.0, SQLite in-memory store, existing ControlledDriver, isolated network-disabled test environment.
  • Original source: crates/openshell-server/src/compute/mod.rs, blob b85641c986aa3a960936d832fac198d41ab0a294.

Minimal Timing Evidence

Gateway and Docker timestamps, UTC on 2026-10-26:

18:23:54.945490  StopSandbox succeeds
18:23:56.192808  replacement container StartedAt
18:23:56.234658  reconcile.sandboxes changes Starting -> Error
18:23:56.234679  ContainerExited / Container exited
18:23:56.324333  StartSandbox completes successfully

The replacement supervisor subsequently launched; Docker inspection reported running=true, OOMKilled=false, restarting=false. This does not alone establish gateway usability.

Candidate Repair and Verification

The local 0.0.116 patch holds the existing per-sandbox lifecycle gate across the fresh backend lookup and conditional write in both sweep paths. It preserves lifecycle-before-global lock ordering and releases the global lock during driver I/O. This is process-local protection, not distributed fencing; a sweep may wait for the active lifecycle operation.

  • Original production source plus new tests: 0 passed, 3 failed.
  • Patched compute suite: 116 passed, 0 failed, 2 pre-existing ignored tracing tests, 0.98 seconds.
  • The two pre-existing ignored tests pass separately with --ignored --test-threads=1, 0.14 seconds; no ignore annotations were changed.
  • Rustfmt and git diff --check pass.
  • Full workspace CI, patched Docker E2E, current-main qualification, and release artifacts remain outstanding.

Please assess this issue for acceptance and confirm the supported integration/backport target. No PR or branch has been pushed, no release has been made, and no dependency consumer has adopted this patch.

Reproducible candidate patch against v0.0.116
diff --git a/architecture/gateway.md b/architecture/gateway.md
index dcb1dd5..ae30e12 100644
--- a/architecture/gateway.md
+++ b/architecture/gateway.md
@@ -377,6 +377,13 @@ This matters for HA deployments where multiple gateway replicas share the same
 Postgres database, and for single-node deployments where concurrent gRPC
 handlers or the reconciler mutate the same sandbox.
 
+Within one gateway process, reconciliation also holds the sandbox's lifecycle
+gate across its fresh backend lookup and conditional write, including pruning
+checks for missing containers. CAS alone cannot protect a `Starting` row while
+its driver operation is still in flight. The lifecycle gate is acquired before
+the global lock; the global lock is released during backend I/O so unrelated
+sandbox events can proceed. The gate is process-local, not a cross-replica fence.
+
 **Compile-time enforcement.** The unconditional write methods `put` and
 `put_message` are gated behind `#[cfg(test)]`. Production code must use
 `put_if` with an explicit `WriteCondition` or `update_message_cas`. The
diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs
index b85641c..8b186eb 100644
--- a/crates/openshell-server/src/compute/mod.rs
+++ b/crates/openshell-server/src/compute/mod.rs
@@ -3179,8 +3179,11 @@ impl ComputeRuntime {
         snapshot: DriverSandbox,
         sweep_started_at_ms: i64,
     ) -> Result<(), String> {
+        // A committed Starting row can still describe an in-flight driver start.
+        // Hold the lifecycle gate across the fresh backend read and its write.
+        let lifecycle_guard = self.lifecycle_gates.lock_for(&snapshot.id).await;
         let expected_resource_version = {
-            let _guard = self.sync_lock.lock().await;
+            let _guard = self.lock_global_for_lifecycle(&lifecycle_guard).await;
             let Some(existing) = self
                 .store
                 .get(Sandbox::object_type(), &snapshot.id)
@@ -3203,7 +3206,7 @@ impl ComputeRuntime {
             return Ok(());
         };
 
-        let _guard = self.sync_lock.lock().await;
+        let _guard = self.lock_global_for_lifecycle(&lifecycle_guard).await;
         let Some(existing) = self
             .store
             .get(Sandbox::object_type(), &snapshot.id)
@@ -3228,8 +3231,9 @@ impl ComputeRuntime {
         sweep_started_at_ms: i64,
         grace_ms: i64,
     ) -> Result<(), String> {
+        let lifecycle_guard = self.lifecycle_gates.lock_for(&record.id).await;
         let (sandbox_id, sandbox_name, expected_resource_version, age_ms) = {
-            let _guard = self.sync_lock.lock().await;
+            let _guard = self.lock_global_for_lifecycle(&lifecycle_guard).await;
             let Some(current_record) = self
                 .store
                 .get(Sandbox::object_type(), &record.id)
@@ -3260,7 +3264,7 @@ impl ComputeRuntime {
 
         let current = self.get_driver_sandbox(&sandbox_id, &sandbox_name).await?;
 
-        let _guard = self.sync_lock.lock().await;
+        let _guard = self.lock_global_for_lifecycle(&lifecycle_guard).await;
         let Some(current_record) = self
             .store
             .get(Sandbox::object_type(), &sandbox_id)
@@ -7748,6 +7752,137 @@ mod tests {
         );
     }
 
+    #[derive(Clone, Copy)]
+    enum RestartSweepCase {
+        ListedExited,
+        UnlistedExited,
+        Missing,
+    }
+
+    async fn assert_sweep_waits_for_start(case: RestartSweepCase) {
+        let driver = ControlledDriver::new();
+        driver.block_start();
+        let runtime = test_runtime(driver.clone()).await;
+        let sandbox = sandbox_record("sb-restart", "sandbox-restart", SandboxPhase::Stopped);
+        runtime.store.put_message(&sandbox).await.unwrap();
+        let mut exited = ready_driver_sandbox(sandbox.object_id(), sandbox.object_name());
+        exited.status = Some(make_driver_status(make_driver_condition(
+            "ContainerExited",
+            "previous container exited",
+        )));
+        driver.set_get_outcome(match case {
+            RestartSweepCase::Missing => ControlledGetOutcome::Missing,
+            _ => ControlledGetOutcome::Sandbox(Box::new(exited.clone())),
+        });
+
+        let start_runtime = runtime.clone();
+        let start = tokio::spawn(async move {
+            start_runtime
+                .start_sandbox("default", "sandbox-restart")
+                .await
+        });
+        tokio::time::timeout(Duration::from_secs(1), driver.start_started.notified())
+            .await
+            .expect("start did not reach the paused driver");
+
+        let reconcile_runtime = runtime.clone();
+        let mut reconcile = tokio::spawn(async move {
+            match case {
+                RestartSweepCase::ListedExited => {
+                    reconcile_runtime
+                        .reconcile_snapshot_sandbox(exited, i64::MAX)
+                        .await
+                }
+                _ => {
+                    let record = reconcile_runtime
+                        .store
+                        .get(Sandbox::object_type(), &exited.id)
+                        .await
+                        .unwrap()
+                        .unwrap();
+                    reconcile_runtime
+                        .prune_missing_sandbox(record, i64::MAX, 0)
+                        .await
+                }
+            }
+        });
+        let early = tokio::time::timeout(Duration::from_millis(100), &mut reconcile).await;
+
+        // Replace the old exit observation before allowing the owned start to finish.
+        driver.set_get_outcome(ControlledGetOutcome::Sandbox(Box::new(
+            ready_driver_sandbox(sandbox.object_id(), sandbox.object_name()),
+        )));
+        register_test_supervisor_session(&runtime, sandbox.object_id());
+        driver.release_start();
+        let started = tokio::time::timeout(Duration::from_secs(1), start)
+            .await
+            .unwrap()
+            .unwrap()
+            .unwrap();
+        let reconciled_early = early.is_ok();
+        match early {
+            Ok(result) => result.unwrap().unwrap(),
+            Err(_) => tokio::time::timeout(Duration::from_secs(1), reconcile)
+                .await
+                .unwrap()
+                .unwrap()
+                .unwrap(),
+        }
+        let stored = runtime
+            .store
+            .get_message::<Sandbox>(sandbox.object_id())
+            .await
+            .unwrap()
+            .unwrap();
+        assert_eq!(
+            started.phase(),
+            SandboxPhase::Starting as i32,
+            "a stopped-container snapshot must not poison an active start"
+        );
+        assert!(
+            !reconciled_early,
+            "reconciliation must wait for the lifecycle owner"
+        );
+        assert_eq!(stored.phase(), SandboxPhase::Ready as i32);
+
+        let mut failed = ready_driver_sandbox(sandbox.object_id(), sandbox.object_name());
+        failed.status = Some(make_driver_status(make_driver_condition(
+            "ContainerExited",
+            "replacement container exited",
+        )));
+        driver.set_get_outcome(ControlledGetOutcome::Sandbox(Box::new(failed.clone())));
+        runtime
+            .reconcile_snapshot_sandbox(failed, i64::MAX)
+            .await
+            .unwrap();
+        let stored = runtime
+            .store
+            .get_message::<Sandbox>(sandbox.object_id())
+            .await
+            .unwrap()
+            .unwrap();
+        assert_eq!(
+            stored.phase(),
+            SandboxPhase::Error as i32,
+            "a genuine exit after start must still fail"
+        );
+    }
+
+    #[tokio::test]
+    async fn reconciliation_waits_for_start_before_reading_backend() {
+        assert_sweep_waits_for_start(RestartSweepCase::ListedExited).await;
+    }
+
+    #[tokio::test]
+    async fn pruning_exited_waits_for_start_before_reading_backend() {
+        assert_sweep_waits_for_start(RestartSweepCase::UnlistedExited).await;
+    }
+
+    #[tokio::test]
+    async fn pruning_missing_waits_for_start_before_reading_backend() {
+        assert_sweep_waits_for_start(RestartSweepCase::Missing).await;
+    }
+
     #[tokio::test]
     async fn blocked_reconciliation_lookup_does_not_delay_watch_events() {
         let driver = ControlledDriver::new();

Activity

  1. prekshivyas commented on Oct 26, 2026

    @prekshivyas
    ContributorAuthor

    Current-source reproduction and bounded candidate

    The same persisted-error mechanism also reproduces on current main, d0a163a4284bdcd6ab656aa008e62192b7c3ed34, not just 0.0.116. No production source was changed for the baseline.

    Two tests fail on that base with Error (3) instead of Starting (8):

    • reconciliation_does_not_poison_inflight_start: pause the real start after driver dispatch, run the periodic snapshot reconciliation against the old exit, then complete start.
    • reconciliation_does_not_poison_pending_driver_operation: retain the durable pending-operation record without a local lifecycle owner, then reconcile the old exit.

    Current main has durable provisioning-operation tracking that 0.0.116 lacks. A four-line candidate mirrors the existing pruning check in reconcile_snapshot_sandbox: decode the current record and defer while driver_operation_pending is true. The existing resource-version checks remain unchanged. Tests verify that configuration admission still blocks Ready, the admitted replacement becomes Ready, and genuine failures are still reported after settlement.

    Verification of this scoped candidate: 346 compute tests passed, 0 failed, 3 existing PostgreSQL-only tests ignored, in 2.66 seconds. Command: cargo test --locked --offline -p openshell-server --lib --features vendored-z3 compute::. Rust 1.95.0, Linux aarch64, isolated network-disabled test environment. Rustfmt and diff whitespace checks passed. Clippy, PostgreSQL and Docker E2E have not run.

    Limits requiring owner review

    This is not an integration-ready, comprehensive lifecycle repair. The pending check protects the demonstrated post-claim overlap; source review identifies additional boundaries to qualify:

    • Explicit start writes Starting before claim_provisioning_operation sets the pending marker. The pre-claim window needs separate coverage/fencing.
    • Legacy Starting rows without a provisioning record do not have this marker and retain their existing behavior.

    I tried a direct lifecycle-gate port, but rejected it for current main. It initially broke two pruning-cleanup tests. Passing the gate into background cleanup repaired those tests, but further review found that deletion-watch cleanup also assumes a busy gate holder owns cleanup. Extending gate ownership to reconciliation would require broader cleanup coordination. That candidate is not proposed for integration.

    The 0.0.116 patch in the original report and this scoped current-main candidate are different, separately tested artifacts. Neither has been pushed, shipped, or adopted by a dependency consumer. Please assess acceptance and the intended target/backport path before a repair PR; additional source repair and E2E qualification remain necessary.

    Current scoped patch SHA-256: 72f98cd4e2bc9db4895ede795b52a3ae329b32a9967037b67055f99c78e4a50a.

    Scoped current-main candidate and regression tests, not an admitted repair
    diff --git a/crates/openshell-server/README.md b/crates/openshell-server/README.md
    index 58f735c..4543c91 100644
    --- a/crates/openshell-server/README.md
    +++ b/crates/openshell-server/README.md
    @@ -18,6 +18,11 @@ Retries reuse that generation and session, including after gateway restart.
     Credential renewal retains both; credentials for an older launch cannot
     authorize the new one.
     
    +Periodic backend reconciliation and missing-resource pruning defer observations
    +while a durable driver operation is pending. An old exited or absent runtime is
    +not evidence that the replacement failed while its start is still in flight.
    +After settlement, subsequent sweeps resume normal readiness and failure handling.
    +
     ## Operator credential export
     
     `GetProviderCredentials` exports selected profile-declared runtime environment
    diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs
    index 1ec4cfd..7bc6b93 100644
    --- a/crates/openshell-server/src/compute/mod.rs
    +++ b/crates/openshell-server/src/compute/mod.rs
    @@ -5950,6 +5950,10 @@ impl ComputeRuntime {
                 if existing.updated_at_ms > sweep_started_at_ms {
                     return Ok(());
                 }
    +            let sandbox = decode_sandbox_record(&existing)?;
    +            if provisioning_deadline::driver_operation_pending(&sandbox) {
    +                return Ok(());
    +            }
                 existing.resource_version
             };
     
    @@ -14939,6 +14943,191 @@ mod tests {
             );
         }
     
    +    #[tokio::test]
    +    async fn reconciliation_does_not_poison_inflight_start() {
    +        let driver = ControlledDriver::new();
    +        driver.block_start();
    +        let runtime = test_runtime(driver.clone()).await;
    +        let sandbox = sandbox_record("sb-restart", "sandbox-restart", SandboxPhase::Stopped);
    +        runtime.store.put_message(&sandbox).await.unwrap();
    +        let mut exited = ready_driver_sandbox(sandbox.object_id(), sandbox.object_name());
    +        exited.status = Some(make_driver_status(make_driver_condition(
    +            "ContainerExited",
    +            "previous container exited",
    +        )));
    +        driver.set_get_outcome(ControlledGetOutcome::Sandbox(Box::new(exited.clone())));
    +
    +        let start_runtime = runtime.clone();
    +        let start = tokio::spawn(async move {
    +            start_runtime
    +                .start_sandbox("default", "sandbox-restart")
    +                .await
    +        });
    +        tokio::time::timeout(Duration::from_secs(1), driver.start_started.notified())
    +            .await
    +            .expect("start did not reach the paused driver");
    +
    +        let reconcile_runtime = runtime.clone();
    +        let mut reconcile = tokio::spawn(async move {
    +            reconcile_runtime
    +                .reconcile_snapshot_sandbox(exited, i64::MAX)
    +                .await
    +        });
    +        let early = tokio::time::timeout(Duration::from_millis(100), &mut reconcile).await;
    +        let during_start = runtime
    +            .store
    +            .get_message::<Sandbox>(sandbox.object_id())
    +            .await
    +            .unwrap()
    +            .unwrap();
    +
    +        // Replace the old exit observation before allowing the owned start to finish.
    +        driver.set_get_outcome(ControlledGetOutcome::Sandbox(Box::new(
    +            ready_driver_sandbox(sandbox.object_id(), sandbox.object_name()),
    +        )));
    +        register_test_supervisor_session(&runtime, sandbox.object_id());
    +        driver.release_start();
    +        let started = tokio::time::timeout(Duration::from_secs(1), start)
    +            .await
    +            .unwrap()
    +            .unwrap();
    +        match early {
    +            Ok(result) => result.unwrap().unwrap(),
    +            Err(_) => tokio::time::timeout(Duration::from_secs(1), reconcile)
    +                .await
    +                .unwrap()
    +                .unwrap()
    +                .unwrap(),
    +        }
    +        assert_eq!(during_start.phase(), SandboxPhase::Starting as i32);
    +        assert_eq!(
    +            started.unwrap().phase(),
    +            SandboxPhase::Starting as i32,
    +            "a stopped-container snapshot must not poison an active start"
    +        );
    +        let ready = ready_driver_sandbox(sandbox.object_id(), sandbox.object_name());
    +        runtime
    +            .reconcile_snapshot_sandbox(ready.clone(), i64::MAX)
    +            .await
    +            .unwrap();
    +        let awaiting_admission = runtime
    +            .store
    +            .get_message::<Sandbox>(sandbox.object_id())
    +            .await
    +            .unwrap()
    +            .unwrap();
    +        assert_eq!(
    +            awaiting_admission.phase(),
    +            SandboxPhase::Starting as i32,
    +            "backend readiness must not bypass restart configuration admission"
    +        );
    +        // Model the same admission completion as the existing lifecycle test.
    +        runtime
    +            .store
    +            .update_message_cas::<Sandbox, _>(sandbox.object_id(), 0, |sandbox| {
    +                let status = sandbox.status.as_mut().unwrap();
    +                provisioning_deadline::record_admission_start(
    +                    status.provisioning.as_mut().unwrap(),
    +                    openshell_core::time::now_ms(),
    +                )
    +                .unwrap();
    +                status.configuration_admission.as_mut().unwrap().state =
    +                    openshell_core::proto::ConfigurationAdmissionState::Accepted.into();
    +            })
    +            .await
    +            .unwrap();
    +        runtime
    +            .reconcile_snapshot_sandbox(ready, i64::MAX)
    +            .await
    +            .unwrap();
    +        let stored = runtime
    +            .store
    +            .get_message::<Sandbox>(sandbox.object_id())
    +            .await
    +            .unwrap()
    +            .unwrap();
    +        assert_eq!(stored.phase(), SandboxPhase::Ready as i32);
    +
    +        let mut failed = ready_driver_sandbox(sandbox.object_id(), sandbox.object_name());
    +        failed.status = Some(make_driver_status(make_driver_condition(
    +            "ContainerExited",
    +            "replacement container exited",
    +        )));
    +        driver.set_get_outcome(ControlledGetOutcome::Sandbox(Box::new(failed.clone())));
    +        runtime
    +            .reconcile_snapshot_sandbox(failed, i64::MAX)
    +            .await
    +            .unwrap();
    +        let stored = runtime
    +            .store
    +            .get_message::<Sandbox>(sandbox.object_id())
    +            .await
    +            .unwrap()
    +            .unwrap();
    +        assert_eq!(
    +            stored.phase(),
    +            SandboxPhase::Error as i32,
    +            "a genuine exit after start must still fail"
    +        );
    +    }
    +
    +    #[tokio::test]
    +    async fn reconciliation_does_not_poison_pending_driver_operation() {
    +        let driver = ControlledDriver::new();
    +        let runtime = test_runtime(driver.clone()).await;
    +        let mut sandbox = sandbox_record("sb-pending", "sandbox-pending", SandboxPhase::Starting);
    +        sandbox.status.as_mut().unwrap().provisioning = Some(
    +            provisioning_deadline::new_preparation_record(openshell_core::time::now_ms(), 300),
    +        );
    +        provisioning_operation::claim_record(&mut sandbox);
    +        runtime.store.put_message(&sandbox).await.unwrap();
    +        let mut exited = ready_driver_sandbox(sandbox.object_id(), sandbox.object_name());
    +        exited.status = Some(make_driver_status(make_driver_condition(
    +            "ContainerExited",
    +            "previous container exited",
    +        )));
    +        driver.set_get_outcome(ControlledGetOutcome::Sandbox(Box::new(exited.clone())));
    +
    +        // A detached operation can still be pending without a local lifecycle owner.
    +        runtime
    +            .reconcile_snapshot_sandbox(exited.clone(), i64::MAX)
    +            .await
    +            .unwrap();
    +        let stored = runtime
    +            .store
    +            .get_message::<Sandbox>(sandbox.object_id())
    +            .await
    +            .unwrap()
    +            .unwrap();
    +        assert_eq!(stored.phase(), SandboxPhase::Starting as i32);
    +
    +        runtime
    +            .store
    +            .update_message_cas::<Sandbox, _>(sandbox.object_id(), 0, |sandbox| {
    +                sandbox
    +                    .status
    +                    .as_mut()
    +                    .unwrap()
    +                    .provisioning
    +                    .as_mut()
    +                    .unwrap()
    +                    .driver_operation_pending = false;
    +            })
    +            .await
    +            .unwrap();
    +        runtime
    +            .reconcile_snapshot_sandbox(exited, i64::MAX)
    +            .await
    +            .unwrap();
    +        let stored = runtime
    +            .store
    +            .get_message::<Sandbox>(sandbox.object_id())
    +            .await
    +            .unwrap()
    +            .unwrap();
    +        assert_eq!(stored.phase(), SandboxPhase::Error as i32);
    +    }
    +
         #[tokio::test]
         async fn blocked_reconciliation_lookup_does_not_delay_watch_events() {
             let driver = ControlledDriver::new();
    

    Continuation: atomic explicit-start claim and legacy sweep protection (2026-10-26)

    The two gaps identified above now have a broader local source candidate on the same main snapshot, d0a163a4284bdcd6ab656aa008e62192b7c3ed34. This candidate supersedes the four-line candidate for further review; neither candidate has been published as a branch, shipped or adopted by a consumer.

    • Explicit start commits Starting, launch identity and the durable driver claim in one CAS. A retry claims without rotating identity, after revalidating the phase, generation and settled operation.
    • Fallible pre-dispatch preparation runs inside the claimed worker, so a known preparation failure settles ownership through the existing result/recovery path.
    • Reconciliation and pruning retain the durable pending check. For legacy Starting rows without a provisioning record, they also probe local lifecycle ownership under the sandbox mutation lock before backend lookup and before applying observations. Probes are dropped immediately. They are never held across backend I/O or deletion cleanup. The retry CAS also advances the legacy row version without adding a synthetic deadline.

    The rejected approach described above remains rejected: this candidate does not hold a sweep-owned lifecycle gate during backend I/O or pass one into asynchronous cleanup.

    New regression evidence against the inherited four-line candidate, before applying this repair:

    • start_commits_driver_claim_with_starting_transition: fails because the first durable Starting payload has no pending claim. A SQLite trigger records actual persisted writes, rather than only checking the eventual row.
    • reconciliation_does_not_poison_legacy_start: fails with Error (3), not Starting (8), after a successful start.

    Final local verification: 350 compute tests passed, 0 failed, 3 unchanged PostgreSQL-only tests ignored, 2.68 seconds. The added tests cover the atomic claim, legacy exited/missing observations and genuine later exit, known pre-dispatch preparation failure without dispatch, and deletion-watch driver cleanup for the same sandbox while a sweep lookup is blocked. Existing admission, readiness, cancellation/recovery and cleanup tests also pass. The older resource-version test now requires the combined transition/claim write plus settlement; identity and authentication assertions remain intact.

    Command: cargo test --locked --offline -p openshell-server --lib --features vendored-z3 compute:: -- --nocapture. Rust 1.95.0, isolated Linux ARM64, network disabled. Rustfmt and git diff --check pass. Patch SHA-256: 65489cfe02314e021cc7762ddeaf463ac0b0f20e77f84875a42def01ca9fa38b. Test-log SHA-256: 580ccc0aca37f314205ae2787dd6e9e8ab31e115900cd89083555cdbea2c0804.

    Limits: legacy protection is process-local; no distributed-fencing claim is made for records without durable provisioning state. PostgreSQL integration, Clippy, full workspace CI, Docker E2E and a reviewed replacement runtime remain outstanding. Issue acceptance and the integration/backport target are still needed before a non-write contributor pushes a branch or opens a PR under the repository policy. No label, release, consumer pin, installed Windows application or credential was changed.

    Local atomic-start candidate against d0a163a, for source review
    diff --git a/crates/openshell-server/README.md b/crates/openshell-server/README.md
    index 58f735c..bd13861 100644
    --- a/crates/openshell-server/README.md
    +++ b/crates/openshell-server/README.md
    @@ -18,6 +18,17 @@ Retries reuse that generation and session, including after gateway restart.
     Credential renewal retains both; credentials for an older launch cannot
     authorize the new one.
     
    +Periodic backend reconciliation and missing-resource pruning defer observations
    +while a durable driver operation is pending. An old exited or absent runtime is
    +not evidence that the replacement failed while its start is still in flight.
    +Explicit start commits that claim with its `Starting` transition before dispatch.
    +Retries claim under the same CAS boundary without rotating launch identity.
    +Legacy rows without provisioning records retain process-local protection: sweeps
    +check lifecycle ownership under the sandbox mutation lock before backend I/O
    +and before applying observations. They release the probe immediately; sweeps do
    +not own the lifecycle gate during backend I/O or suppress deletion-watch cleanup.
    +After settlement, subsequent sweeps resume normal readiness and failure handling.
    +
     ## Operator credential export
     
     `GetProviderCredentials` exports selected profile-declared runtime environment
    diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs
    index 1ec4cfd..a605422 100644
    --- a/crates/openshell-server/src/compute/mod.rs
    +++ b/crates/openshell-server/src/compute/mod.rs
    @@ -2105,7 +2105,28 @@ impl ComputeRuntime {
                         .await
                         .map_err(|_| Status::unavailable("reload sandbox SSH identity failed"))?
                         .ok_or_else(|| Status::not_found("sandbox was deleted"))?;
    -                break (refreshed.clone(), refreshed, authentication);
    +                provisioning_operation::ensure_operation_settled(&refreshed)?;
    +                if refreshed.phase() != current.phase()
    +                    || !provisioning_operation::same_operation(&refreshed, &current)
    +                    || sandbox_runtime_generation(&refreshed)
    +                        != sandbox_runtime_generation(&current)
    +                {
    +                    return Err(Status::aborted("sandbox changed before start claim"));
    +                }
    +                let claimed = self
    +                    .store
    +                    .update_message_cas::<Sandbox, _>(
    +                        &sandbox_id,
    +                        sandbox_resource_version(&refreshed),
    +                        provisioning_operation::claim_record,
    +                    )
    +                    .await
    +                    .map_err(|error| {
    +                        crate::grpc::persistence_error_to_status(error, "claim sandbox start")
    +                    })?;
    +                self.sandbox_index.update_from_sandbox(&claimed);
    +                self.sandbox_watch_bus.notify(&sandbox_id);
    +                break (refreshed, claimed, authentication);
                 }
     
                 let previous = current.clone();
    @@ -2137,6 +2158,7 @@ impl ComputeRuntime {
                                 "Sandbox start requested",
                                 self.image_preparation_timeout_seconds,
                             );
    +                        provisioning_operation::claim_record(sandbox);
                         },
                     )
                     .await
    @@ -2207,60 +2229,69 @@ impl ComputeRuntime {
             lifecycle_guard: SandboxLifecycleGuard,
             launch_authentication: Vec<u8>,
         ) -> Result<Sandbox, Status> {
    -        self.validate_caller_driver_config(
    -            starting
    -                .spec
    -                .as_ref()
    -                .and_then(|spec| spec.template.as_ref()),
    -        )?;
    -        let expected_runtime_identity = sandbox_compute_runtime_identity(&previous);
    -        // One owned operation covers both calls. A NotFound response alone
    -        // does not end ownership while the recovery create can still run.
    -        let recreate = if provisioning_deadline::timed_out(&previous) {
    -            let mut driver_sandbox = self.driver_sandbox_for_launch(&starting)?;
    -            if let Some(spec) = driver_sandbox.spec.as_mut() {
    -                spec.launch_authentication
    -                    .clone_from(&launch_authentication);
    -            }
    -            Some(driver_sandbox)
    -        } else {
    -            None
    -        };
    -        let request =
    -            self.driver_start_request(&starting, launch_authentication, expected_runtime_identity)?;
    +        let runtime = self.clone();
    +        let owned_starting = starting.clone();
    +        let owned_previous = previous.clone();
             let driver = self.driver.clone();
             let operation_id = sandbox_id.clone();
    -        let result = Box::pin(self.await_provisioning_operation(&starting, async move {
    -            let response = driver
    -                .call_owned(
    -                    openshell_otel::rpc::START_SANDBOX,
    -                    Some(&operation_id),
    -                    move |driver| async move { driver.start_sandbox(Request::new(request)).await },
    -                )
    -                .await;
    -            match (response, recreate) {
    -                (Err(error), Some(driver_sandbox)) if error.code() == Code::NotFound => {
    +        let result = Box::pin(
    +            self.await_claimed_provisioning_operation(&starting, async move {
    +                runtime.validate_caller_driver_config(
    +                    owned_starting
    +                        .spec
    +                        .as_ref()
    +                        .and_then(|spec| spec.template.as_ref()),
    +                )?;
    +                let expected_runtime_identity = sandbox_compute_runtime_identity(&owned_previous);
    +                let recreate = if provisioning_deadline::timed_out(&owned_previous) {
    +                    let mut driver_sandbox = runtime.driver_sandbox_for_launch(&owned_starting)?;
    +                    if let Some(spec) = driver_sandbox.spec.as_mut() {
    +                        spec.launch_authentication
    +                            .clone_from(&launch_authentication);
    +                    }
    +                    Some(driver_sandbox)
    +                } else {
    +                    None
    +                };
    +                let request = runtime.driver_start_request(
    +                    &owned_starting,
    +                    launch_authentication,
    +                    expected_runtime_identity,
    +                )?;
    +                let response =
                         driver
                             .call_owned(
    -                            openshell_otel::rpc::CREATE_SANDBOX,
    +                            openshell_otel::rpc::START_SANDBOX,
                                 Some(&operation_id),
                                 move |driver| async move {
    -                                use openshell_core::proto::compute::v1::StartSandboxResponse;
    -                                let response = driver
    -                                    .create_sandbox(Request::new(CreateSandboxRequest {
    -                                        sandbox: Some(driver_sandbox),
    -                                    }))
    -                                    .await?;
    -                                Ok(tonic::Response::new(StartSandboxResponse {
    -                                    runtime_identity: response.into_inner().runtime_identity,
    -                                }))
    +                                driver.start_sandbox(Request::new(request)).await
                                 },
                             )
    -                        .await
    +                        .await;
    +                match (response, recreate) {
    +                    (Err(error), Some(driver_sandbox)) if error.code() == Code::NotFound => {
    +                        driver
    +                            .call_owned(
    +                                openshell_otel::rpc::CREATE_SANDBOX,
    +                                Some(&operation_id),
    +                                move |driver| async move {
    +                                    use openshell_core::proto::compute::v1::StartSandboxResponse;
    +                                    let response = driver
    +                                        .create_sandbox(Request::new(CreateSandboxRequest {
    +                                            sandbox: Some(driver_sandbox),
    +                                        }))
    +                                        .await?;
    +                                    Ok(tonic::Response::new(StartSandboxResponse {
    +                                        runtime_identity: response.into_inner().runtime_identity,
    +                                    }))
    +                                },
    +                            )
    +                            .await
    +                    }
    +                    (response, _) => response,
                     }
    -                (response, _) => response,
    -            }
    -        }))
    +            }),
    +        )
             .await;
     
             match result {
    @@ -5950,6 +5981,13 @@ impl ComputeRuntime {
                 if existing.updated_at_ms > sweep_started_at_ms {
                     return Ok(());
                 }
    +            let sandbox = decode_sandbox_record(&existing)?;
    +            if provisioning_deadline::driver_operation_pending(&sandbox)
    +                || (sandbox.phase() == i32::from(SandboxPhase::Starting)
    +                    && self.lifecycle_gates.try_lock_for(&snapshot.id).is_none())
    +            {
    +                return Ok(());
    +            }
                 existing.resource_version
             };
     
    @@ -5974,6 +6012,12 @@ impl ComputeRuntime {
             {
                 return Ok(());
             }
    +        let sandbox = decode_sandbox_record(&existing)?;
    +        if sandbox.phase() == i32::from(SandboxPhase::Starting)
    +            && self.lifecycle_gates.try_lock_for(&snapshot.id).is_none()
    +        {
    +            return Ok(());
    +        }
     
             self.apply_sandbox_update_locked(current, Some(existing))
                 .await
    @@ -6001,7 +6045,10 @@ impl ComputeRuntime {
                 }
     
                 let sandbox = decode_sandbox_record(&current_record)?;
    -            if provisioning_deadline::driver_operation_pending(&sandbox) {
    +            if provisioning_deadline::driver_operation_pending(&sandbox)
    +                || (sandbox.phase() == i32::from(SandboxPhase::Starting)
    +                    && self.lifecycle_gates.try_lock_for(&record.id).is_none())
    +            {
                     return Ok(());
                 }
                 let age_ms = openshell_core::time::now_ms()
    @@ -6035,6 +6082,12 @@ impl ComputeRuntime {
             {
                 return Ok(());
             }
    +        let sandbox = decode_sandbox_record(&current_record)?;
    +        if sandbox.phase() == i32::from(SandboxPhase::Starting)
    +            && self.lifecycle_gates.try_lock_for(&sandbox_id).is_none()
    +        {
    +            return Ok(());
    +        }
     
             if let Some(current) = current {
                 return self
    @@ -12667,8 +12720,8 @@ mod tests {
                 );
                 assert_eq!(
                     sandbox_resource_version(&stored),
    -                sandbox_resource_version(&before) + 3,
    -                "identity/Starting transition, operation claim, and settlement are separate CAS writes"
    +                sandbox_resource_version(&before) + 2,
    +                "identity, Starting, and driver ownership commit together before settlement"
                 );
     
                 runtime
    @@ -14939,6 +14992,366 @@ mod tests {
             );
         }
     
    +    #[tokio::test]
    +    async fn start_commits_driver_claim_with_starting_transition() {
    +        let driver = ControlledDriver::new();
    +        driver.block_start();
    +        let directory = tempfile::tempdir().unwrap();
    +        let database_url = format!("sqlite://{}", directory.path().join("gateway.db").display());
    +        let mut runtime = test_runtime(driver.clone()).await;
    +        runtime.store = Arc::new(Store::connect(&database_url).await.unwrap());
    +        let pool = sqlx::SqlitePool::connect(&database_url).await.unwrap();
    +        let sandbox = sandbox_record("sb-claim", "sandbox-claim", SandboxPhase::Stopped);
    +        runtime.store.put_message(&sandbox).await.unwrap();
    +        sqlx::query("CREATE TABLE start_writes (payload BLOB NOT NULL)")
    +            .execute(&pool)
    +            .await
    +            .unwrap();
    +        sqlx::query("CREATE TRIGGER record_start AFTER UPDATE ON objects WHEN NEW.id = 'sb-claim' BEGIN INSERT INTO start_writes (payload) VALUES (NEW.payload); END")
    +            .execute(&pool).await.unwrap();
    +        let starting_runtime = runtime.clone();
    +        let start = tokio::spawn(async move {
    +            starting_runtime
    +                .start_sandbox("default", "sandbox-claim")
    +                .await
    +        });
    +        tokio::time::timeout(Duration::from_secs(1), driver.start_started.notified())
    +            .await
    +            .unwrap();
    +        let writes: Vec<Vec<u8>> =
    +            sqlx::query_scalar("SELECT payload FROM start_writes ORDER BY rowid")
    +                .fetch_all(&pool)
    +                .await
    +                .unwrap();
    +        driver.release_start();
    +        start.await.unwrap().unwrap();
    +        let first = writes
    +            .iter()
    +            .map(|payload| Sandbox::decode(payload.as_slice()).unwrap())
    +            .find(|sandbox| sandbox.phase() == i32::from(SandboxPhase::Starting))
    +            .unwrap();
    +        assert!(
    +            provisioning_deadline::driver_operation_pending(&first),
    +            "Starting must never be visible before its driver ownership claim"
    +        );
    +    }
    +
    +    #[tokio::test]
    +    async fn reconciliation_does_not_poison_legacy_start() {
    +        let driver = ControlledDriver::new();
    +        driver.block_start();
    +        let runtime = test_runtime(driver.clone()).await;
    +        let sandbox = sandbox_record("sb-legacy-start", "legacy-start", SandboxPhase::Starting);
    +        assert!(sandbox_provisioning_attempt_id(&sandbox).is_none());
    +        runtime.store.put_message(&sandbox).await.unwrap();
    +        let mut exited = ready_driver_sandbox(sandbox.object_id(), sandbox.object_name());
    +        exited.status = Some(make_driver_status(make_driver_condition(
    +            "ContainerExited",
    +            "old container exited",
    +        )));
    +        driver.set_get_outcome(ControlledGetOutcome::Sandbox(Box::new(exited.clone())));
    +        let starting_runtime = runtime.clone();
    +        let start = tokio::spawn(async move {
    +            starting_runtime
    +                .start_sandbox("default", "legacy-start")
    +                .await
    +        });
    +        tokio::time::timeout(Duration::from_secs(1), driver.start_started.notified())
    +            .await
    +            .unwrap();
    +        runtime
    +            .reconcile_snapshot_sandbox(exited.clone(), i64::MAX)
    +            .await
    +            .unwrap();
    +        let record = runtime
    +            .store
    +            .get(Sandbox::object_type(), sandbox.object_id())
    +            .await
    +            .unwrap()
    +            .unwrap();
    +        runtime
    +            .prune_missing_sandbox(record, i64::MAX, 0)
    +            .await
    +            .unwrap();
    +        driver.set_get_outcome(ControlledGetOutcome::Missing);
    +        let record = runtime
    +            .store
    +            .get(Sandbox::object_type(), sandbox.object_id())
    +            .await
    +            .unwrap()
    +            .unwrap();
    +        runtime
    +            .prune_missing_sandbox(record, i64::MAX, 0)
    +            .await
    +            .unwrap();
    +        driver.release_start();
    +        let started = start.await.unwrap().unwrap();
    +        assert_eq!(started.phase(), i32::from(SandboxPhase::Starting));
    +        assert!(sandbox_provisioning_attempt_id(&started).is_none());
    +        driver.set_get_outcome(ControlledGetOutcome::Sandbox(Box::new(exited.clone())));
    +        runtime
    +            .reconcile_snapshot_sandbox(exited, i64::MAX)
    +            .await
    +            .unwrap();
    +        let failed = runtime
    +            .store
    +            .get_message::<Sandbox>(sandbox.object_id())
    +            .await
    +            .unwrap()
    +            .unwrap();
    +        assert_eq!(failed.phase(), i32::from(SandboxPhase::Error));
    +    }
    +
    +    #[tokio::test]
    +    async fn start_preparation_failure_settles_claim_without_driver_dispatch() {
    +        let driver = ControlledDriver::new();
    +        let runtime = test_runtime(driver.clone()).await;
    +        let mut sandbox =
    +            sandbox_record("sb-prepare-start", "prepare-start", SandboxPhase::Starting);
    +        sandbox.status.as_mut().unwrap().provisioning = Some(
    +            provisioning_deadline::new_preparation_record(openshell_core::time::now_ms(), 300),
    +        );
    +        sandbox.metadata.as_mut().unwrap().annotations.insert(
    +            crate::auth::sandbox_session::RUNTIME_GENERATION_ANNOTATION.to_string(),
    +            String::new(),
    +        );
    +        runtime.store.put_message(&sandbox).await.unwrap();
    +        let error = runtime
    +            .start_sandbox("default", "prepare-start")
    +            .await
    +            .unwrap_err();
    +        assert_eq!(error.code(), Code::FailedPrecondition);
    +        assert_eq!(driver.start_calls(), 0);
    +        let stored = runtime
    +            .store
    +            .get_message::<Sandbox>(sandbox.object_id())
    +            .await
    +            .unwrap()
    +            .unwrap();
    +        assert!(!provisioning_deadline::driver_operation_pending(&stored));
    +        assert_eq!(stored.phase(), i32::from(SandboxPhase::Starting));
    +    }
    +
    +    #[tokio::test]
    +    async fn reconciliation_lookup_preserves_same_sandbox_deletion_cleanup() {
    +        let driver = ControlledDriver::new();
    +        driver.block_get();
    +        let runtime = test_runtime(driver.clone()).await;
    +        let sandbox = sandbox_record("sb-sweep-delete", "sweep-delete", SandboxPhase::Starting);
    +        runtime.store.put_message(&sandbox).await.unwrap();
    +        let snapshot = ready_driver_sandbox(sandbox.object_id(), sandbox.object_name());
    +        driver.set_get_outcome(ControlledGetOutcome::Sandbox(Box::new(snapshot.clone())));
    +        let reconciling = runtime.clone();
    +        let reconcile = tokio::spawn(async move {
    +            reconciling
    +                .reconcile_snapshot_sandbox(snapshot, i64::MAX)
    +                .await
    +        });
    +        tokio::time::timeout(Duration::from_secs(1), driver.get_started.notified())
    +            .await
    +            .unwrap();
    +        runtime.apply_deleted(sandbox.object_id()).await.unwrap();
    +        let cleanup =
    +            tokio::time::timeout(Duration::from_secs(1), driver.delete_started.notified()).await;
    +        driver.release_get();
    +        reconcile.await.unwrap().unwrap();
    +        cleanup.expect("the sweep must not suppress deletion-watch driver cleanup");
    +        assert_eq!(driver.delete_calls(), 1);
    +        assert!(
    +            runtime
    +                .store
    +                .get_message::<Sandbox>(sandbox.object_id())
    +                .await
    +                .unwrap()
    +                .is_none()
    +        );
    +    }
    +
    +    #[tokio::test]
    +    async fn reconciliation_does_not_poison_inflight_start() {
    +        let driver = ControlledDriver::new();
    +        driver.block_start();
    +        let runtime = test_runtime(driver.clone()).await;
    +        let sandbox = sandbox_record("sb-restart", "sandbox-restart", SandboxPhase::Stopped);
    +        runtime.store.put_message(&sandbox).await.unwrap();
    +        let mut exited = ready_driver_sandbox(sandbox.object_id(), sandbox.object_name());
    +        exited.status = Some(make_driver_status(make_driver_condition(
    +            "ContainerExited",
    +            "previous container exited",
    +        )));
    +        driver.set_get_outcome(ControlledGetOutcome::Sandbox(Box::new(exited.clone())));
    +
    +        let start_runtime = runtime.clone();
    +        let start = tokio::spawn(async move {
    +            start_runtime
    +                .start_sandbox("default", "sandbox-restart")
    +                .await
    +        });
    +        tokio::time::timeout(Duration::from_secs(1), driver.start_started.notified())
    +            .await
    +            .expect("start did not reach the paused driver");
    +
    +        let reconcile_runtime = runtime.clone();
    +        let mut reconcile = tokio::spawn(async move {
    +            reconcile_runtime
    +                .reconcile_snapshot_sandbox(exited, i64::MAX)
    +                .await
    +        });
    +        let early = tokio::time::timeout(Duration::from_millis(100), &mut reconcile).await;
    +        let during_start = runtime
    +            .store
    +            .get_message::<Sandbox>(sandbox.object_id())
    +            .await
    +            .unwrap()
    +            .unwrap();
    +
    +        // Replace the old exit observation before allowing the owned start to finish.
    +        driver.set_get_outcome(ControlledGetOutcome::Sandbox(Box::new(
    +            ready_driver_sandbox(sandbox.object_id(), sandbox.object_name()),
    +        )));
    +        register_test_supervisor_session(&runtime, sandbox.object_id());
    +        driver.release_start();
    +        let started = tokio::time::timeout(Duration::from_secs(1), start)
    +            .await
    +            .unwrap()
    +            .unwrap();
    +        match early {
    +            Ok(result) => result.unwrap().unwrap(),
    +            Err(_) => tokio::time::timeout(Duration::from_secs(1), reconcile)
    +                .await
    +                .unwrap()
    +                .unwrap()
    +                .unwrap(),
    +        }
    +        assert_eq!(during_start.phase(), SandboxPhase::Starting as i32);
    +        assert_eq!(
    +            started.unwrap().phase(),
    +            SandboxPhase::Starting as i32,
    +            "a stopped-container snapshot must not poison an active start"
    +        );
    +        let ready = ready_driver_sandbox(sandbox.object_id(), sandbox.object_name());
    +        runtime
    +            .reconcile_snapshot_sandbox(ready.clone(), i64::MAX)
    +            .await
    +            .unwrap();
    +        let awaiting_admission = runtime
    +            .store
    +            .get_message::<Sandbox>(sandbox.object_id())
    +            .await
    +            .unwrap()
    +            .unwrap();
    +        assert_eq!(
    +            awaiting_admission.phase(),
    +            SandboxPhase::Starting as i32,
    +            "backend readiness must not bypass restart configuration admission"
    +        );
    +        // Model the same admission completion as the existing lifecycle test.
    +        runtime
    +            .store
    +            .update_message_cas::<Sandbox, _>(sandbox.object_id(), 0, |sandbox| {
    +                let status = sandbox.status.as_mut().unwrap();
    +                provisioning_deadline::record_admission_start(
    +                    status.provisioning.as_mut().unwrap(),
    +                    openshell_core::time::now_ms(),
    +                )
    +                .unwrap();
    +                status.configuration_admission.as_mut().unwrap().state =
    +                    openshell_core::proto::ConfigurationAdmissionState::Accepted.into();
    +            })
    +            .await
    +            .unwrap();
    +        runtime
    +            .reconcile_snapshot_sandbox(ready, i64::MAX)
    +            .await
    +            .unwrap();
    +        let stored = runtime
    +            .store
    +            .get_message::<Sandbox>(sandbox.object_id())
    +            .await
    +            .unwrap()
    +            .unwrap();
    +        assert_eq!(stored.phase(), SandboxPhase::Ready as i32);
    +
    +        let mut failed = ready_driver_sandbox(sandbox.object_id(), sandbox.object_name());
    +        failed.status = Some(make_driver_status(make_driver_condition(
    +            "ContainerExited",
    +            "replacement container exited",
    +        )));
    +        driver.set_get_outcome(ControlledGetOutcome::Sandbox(Box::new(failed.clone())));
    +        runtime
    +            .reconcile_snapshot_sandbox(failed, i64::MAX)
    +            .await
    +            .unwrap();
    +        let stored = runtime
    +            .store
    +            .get_message::<Sandbox>(sandbox.object_id())
    +            .await
    +            .unwrap()
    +            .unwrap();
    +        assert_eq!(
    +            stored.phase(),
    +            SandboxPhase::Error as i32,
    +            "a genuine exit after start must still fail"
    +        );
    +    }
    +
    +    #[tokio::test]
    +    async fn reconciliation_does_not_poison_pending_driver_operation() {
    +        let driver = ControlledDriver::new();
    +        let runtime = test_runtime(driver.clone()).await;
    +        let mut sandbox = sandbox_record("sb-pending", "sandbox-pending", SandboxPhase::Starting);
    +        sandbox.status.as_mut().unwrap().provisioning = Some(
    +            provisioning_deadline::new_preparation_record(openshell_core::time::now_ms(), 300),
    +        );
    +        provisioning_operation::claim_record(&mut sandbox);
    +        runtime.store.put_message(&sandbox).await.unwrap();
    +        let mut exited = ready_driver_sandbox(sandbox.object_id(), sandbox.object_name());
    +        exited.status = Some(make_driver_status(make_driver_condition(
    +            "ContainerExited",
    +            "previous container exited",
    +        )));
    +        driver.set_get_outcome(ControlledGetOutcome::Sandbox(Box::new(exited.clone())));
    +
    +        // A detached operation can still be pending without a local lifecycle owner.
    +        runtime
    +            .reconcile_snapshot_sandbox(exited.clone(), i64::MAX)
    +            .await
    +            .unwrap();
    +        let stored = runtime
    +            .store
    +            .get_message::<Sandbox>(sandbox.object_id())
    +            .await
    +            .unwrap()
    +            .unwrap();
    +        assert_eq!(stored.phase(), SandboxPhase::Starting as i32);
    +
    +        runtime
    +            .store
    +            .update_message_cas::<Sandbox, _>(sandbox.object_id(), 0, |sandbox| {
    +                sandbox
    +                    .status
    +                    .as_mut()
    +                    .unwrap()
    +                    .provisioning
    +                    .as_mut()
    +                    .unwrap()
    +                    .driver_operation_pending = false;
    +            })
    +            .await
    +            .unwrap();
    +        runtime
    +            .reconcile_snapshot_sandbox(exited, i64::MAX)
    +            .await
    +            .unwrap();
    +        let stored = runtime
    +            .store
    +            .get_message::<Sandbox>(sandbox.object_id())
    +            .await
    +            .unwrap()
    +            .unwrap();
    +        assert_eq!(stored.phase(), SandboxPhase::Error as i32);
    +    }
    +
         #[tokio::test]
         async fn blocked_reconciliation_lookup_does_not_delay_watch_events() {
             let driver = ControlledDriver::new();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    state:triage-neededOpened without agent diagnostics and needs triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions