Skip to content

Control-plane read failures during sandbox SSH-session cleanup; delete acknowledgment can precede durable cleanup #2999

Closed as not planned

Description

@steipete

Control-plane read failures during sandbox SSH-session cleanup; delete acknowledgment can precede durable cleanup

Summary

OpenShell 0.0.109 intermittently fails control-plane reads while deleting sandbox-owned SSH-session records. Separately, sandbox delete can report success before durable sandbox/policy records have disappeared, so an immediately following workspace delete can fail.

These observations came from real Docker testing of the OpenClaw OpenShell integration, including concurrent file operations and commands in both mirror and remote modes. They are independent of the host-gateway policy fixture correction in openclaw/openclaw#130465.

Environment

  • OpenShell CLI/gateway 0.0.109, upstream commit 8d67250a5d17348eb96c4fa46226b06d8041f2ba.
  • Linux aarch64 inside an isolated privileged Docker container; nested Docker uses the vfs storage driver. No host repository, home, or Docker socket is mounted.
  • One gateway, Docker compute driver, mTLS enabled, one managed Docker network, separate non-default test workspaces.
  • File-backed SQLite on the container's overlay filesystem, ?mode=rwc; no journal, busy-timeout, pool, or schema override. The observed journal mode was delete; SQLx 0.8.6 defaults to a five-second busy timeout.
  • The same gateway state/network/dependency versions were retained across controlled diagnostic runs. Each run started without task sandboxes/workspaces; the default workspace was retained.

A. Traced SSH-session cleanup write burst and failed reads

A full mirror-then-remote integration run passed all five foreground tests, including strict workspace deletion and final inventory checks. However, the gateway trace recorded 542 individual store.delete operations for object_type=ssh_session during the approximately 43-second remote cleanup window. These overlapped 22 slow SQL operations and six GetSandboxConfig/GetInferenceBundle INTERNAL responses. Their failed SELECTs returned zero rows and marked the Store span as ERROR after approximately five seconds; the affected RPCs took 5.1–9.9 seconds.

The competing write owner is visible in the parameterized SQL trace. In the pinned source, cleanup_sandbox_ssh_sessions reads up to 1,000 workspace sessions, decodes them, and awaits one DELETE per matching session. Each DELETE is a separate SQLx execution. This identifies the serial cleanup write burst; it does not yet establish the exact SQLite connection-level cause of every failed read.

The Store ERROR field is independently recorded on the nested store span: store_dispatch_traced! marks that span only for unexpected persistence errors, not for an ordinary missing row. GetInferenceBundle maps a missing sandbox to NOT_FOUND, and a missing inference route to Ok(None); persistence errors become INTERNAL. For example, the traced inference-route SELECT at 01:51:10.190813 UTC marked its own store.get_by_name span ERROR after 5.0965 seconds, followed by an INTERNAL RPC response. The trace does not capture the underlying SQLite error text for those six RPCs, so this report does not label them all SQLITE_BUSY. Only C below captured literal code 5.

The same run's fcntl/fsync trace showed repeated short process-wide locks: maximum observed PENDING interval 0.379 seconds, EXCLUSIVE 0.378 seconds, and SHARED 0.117 seconds. Individual sync calls were below 0.22 seconds. There was no single observed five-second kernel lock. Repeated-write starvation is a hypothesis, not proof of a leaked transaction; SQLite's same-process connection locking can also occur without a kernel call.

Source:

  • .store
    .list(SshSession::object_type(), workspace, 1000, 0)
    .await
    .map_err(|e| format!("list SSH sessions: {e}"))?;
    for record in records {
    if let Ok(session) = SshSession::decode(record.payload.as_slice())
    && session.sandbox_id == sandbox_id
    {
    self.store
    .delete(SshSession::object_type(), session.object_id())
    .await
    .map_err(|e| format!("delete SSH session {}: {e}", session.object_id()))?;
    }
    }
    Ok(())
    }
    async fn cleanup_stopped_sandbox_sessions(&self, sandbox: &Sandbox) -> Result<(), String> {
    // Disconnect first so a store failure cannot leave the stopped
    // sandbox reachable through an existing supervisor stream. Both
    // operations are idempotent and are retried for durable Stopped
    // records during explicit stop requests and startup recovery.
    self.supervisor_sessions.disconnect(sandbox.object_id());
    self.cleanup_sandbox_ssh_sessions(sandbox.object_id(), sandbox.object_workspace())
  • pub async fn delete(&self, object_type: &str, id: &str) -> PersistenceResult<bool> {
    let result = sqlx::query(
    r#"
    DELETE FROM "objects"
    WHERE "object_type" = ?1 AND "id" = ?2
    "#,
    )
    .bind(object_type)
    .bind(id)
    .execute(&self.pool)
    .await
    .map_err(|e| map_db_error(&e))?;
    Ok(result.rows_affected() > 0)
    }
  • https://github.com/launchbadge/sqlx/blob/v0.8.6/sqlx-core/src/logger.rs
  • Self::Sqlite(s) => s.$method($($arg),*).await,
    }
    };
    }
    /// [`store_dispatch`] for methods carrying a span, marking that span failed
    /// unless the error is one the caller is expected to act on.
    macro_rules! store_dispatch_traced {
    ($self:ident . $method:ident ( $($arg:expr),* )) => {{
    let result = store_dispatch!($self.$method($($arg),*));
    if let Err(err) = &result
    && !err.is_expected()
    {
    crate::otel_tracing::mark_error(&tracing::Span::current());
    }
  • async fn get_inference_bundle(
    &self,
    request: Request<GetInferenceBundleRequest>,
    ) -> Result<Response<GetInferenceBundleResponse>, Status> {
    let sandbox_id = authorize_inference_bundle(
    request
    .extensions()
    .get::<crate::auth::principal::Principal>(),
    )?;
    let sandbox: Sandbox = self
    .state
    .store
    .get_message::<Sandbox>(&sandbox_id)
    .await
    .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?
    .ok_or_else(|| Status::not_found(format!("sandbox '{sandbox_id}' not found")))?;
    let workspace = sandbox.object_workspace();
    resolve_inference_bundle_with_credentials(
    self.state.store.as_ref(),
    workspace,
    Some(&self.state.credentials),
    )
    .await
    .map(Response::new)
    }
  • resolve_route_by_name_with_credentials(store, workspace, None, route_name).await
    }
    async fn resolve_route_by_name_with_credentials(
    store: &Store,
    workspace: &str,
    credentials: Option<&crate::credentials::CredentialRuntime>,
    route_name: &str,
    ) -> Result<Option<ResolvedRoute>, Status> {
    let route = store
    .get_message_by_name::<InferenceRoute>(workspace, route_name)
    .await
    .map_err(|e| Status::internal(format!("fetch route failed: {e}")))?;
    let Some(route) = route else {
    return Ok(None);
    };
    let Some(config) = route.config.as_ref() else {
    return Ok(None);
    };

B. Separate early deletion acknowledgement

A small CLI-only sequence created three sandboxes, exercised deny/allow networking and 32 serial SSH commands, generated 578 additional ssh-config files without executing them, then deleted the three sandboxes followed immediately by the workspace. Those extra config generations did not allocate sessions: the actual CreateSshSession RPC count remained 37. All three sandbox-delete commands returned exit 0 and printed that the sandbox was deleted. Workspace deletion then returned exit 1 because sandbox and sandbox-policy resources remained. The next all-workspaces sandbox inventory was empty; the workspace remained Terminating. There were no slow SQL warnings in this run.

The source matches this race: after the driver returns deleted=true, delete_sandbox_inner calls cleanup_local_state_if_sandbox_absent. That function clears local state only if the durable row is already absent; it returns success when the row still exists. Watcher reconciliation performs the durable cleanup separately. Workspace deletion, meanwhile, synchronously rejects remaining records.

Please clarify whether DeleteSandbox success is intended to mean compute removal only or completed durable cleanup, and which supported completion signal callers should use before deleting the workspace. The API proto and Go SDK docs currently just describe sandbox deletion.

Source:

  • }
    },
    )
    .await;
    match result {
    Ok(response) => {
    let deleted = response.into_inner().deleted;
    if deleted {
    self.cleanup_local_state_if_sandbox_absent(&delete_guard, &target.sandbox_id)
    .await?;
    } else if !self
    .remove_deleting_sandbox_record(&delete_guard, &target.sandbox_id)
    .await
    {
    return Err(Status::internal(
    "compute resource was absent, but gateway cleanup did not complete",
    ));
    }
  • async fn cleanup_local_state_if_sandbox_absent(
    &self,
    delete_guard: &SandboxLifecycleGuard,
    sandbox_id: &str,
    ) -> Result<(), Status> {
    let _guard = self.lock_global_for_lifecycle(delete_guard).await;
    let record = self
    .store
    .get(Sandbox::object_type(), sandbox_id)
    .await
    .map_err(|err| Status::internal(format!("fetch sandbox failed: {err}")))?;
    if record.is_none() {
    self.cleanup_removed_sandbox_state(sandbox_id);
    }
    Ok(())
  • }
    pub(super) async fn handle_delete_workspace(
    state: &Arc<ServerState>,
    request: Request<DeleteWorkspaceRequest>,
    ) -> Result<Response<DeleteWorkspaceResponse>, Status> {
    let name = request.into_inner().name;
    if name.is_empty() {
    return Err(Status::invalid_argument("name is required"));
    }
    if name == DEFAULT_WORKSPACE_NAME {
    return Err(Status::failed_precondition(
    "the default workspace cannot be deleted",
    ));
    }
    let ws: Workspace = state
    .store
    .get_message_by_name("", &name)
    .await
    .map_err(|e| Status::internal(format!("fetch workspace failed: {e}")))?
    .ok_or_else(|| Status::not_found(format!("workspace '{name}' not found")))?;
    let ws_id = ws
    .metadata
    .as_ref()
    .map(|m| m.id.clone())
    .unwrap_or_default();
    let already_terminating = ws
    .metadata
    .as_ref()
    .is_some_and(|m| m.deletion_timestamp_ms != 0);
    // Track the resource_version so the final delete targets exactly this
    // workspace instance (prevents ABA if a same-name workspace is recreated
    // between the blocker scan and the delete).
    let mut delete_version = ws.metadata.as_ref().map_or(0, |m| m.resource_version);
    if !already_terminating {
    let cas_result = state
    .store
    .update_message_cas::<Workspace, _>(&ws_id, 0, |w| {
    let now_ms = current_time_ms();
    if let Some(meta) = w.metadata.as_mut() {
    meta.deletion_timestamp_ms = now_ms;
    }
    w.status = Some(WorkspaceStatus {
    phase: WorkspacePhase::Terminating.into(),
    });
    })
    .await;
    match cas_result {
    Ok(updated) => {
    delete_version = updated.metadata.as_ref().map_or(0, |m| m.resource_version);
    }
    Err(e) => {
    if matches!(e, crate::persistence::PersistenceError::Conflict { .. }) {
    let refreshed: Option<Workspace> = state
    .store
    .get_message_by_name("", &name)
    .await
    .map_err(|e| Status::internal(format!("workspace re-fetch failed: {e}")))?;
    let refreshed = refreshed.ok_or_else(|| {
    Status::not_found(format!("workspace '{name}' not found"))
    })?;
    let now_terminating = refreshed
    .metadata
    .as_ref()
    .is_some_and(|m| m.deletion_timestamp_ms != 0);
    if !now_terminating {
    return Err(Status::aborted(
    "workspace was concurrently modified, please retry",
    ));
    }
    delete_version = refreshed
    .metadata
    .as_ref()
    .map_or(0, |m| m.resource_version);
    } else {
    return Err(Status::internal(format!(
    "mark workspace terminating failed: {e}"
    )));
    }
    }
    }
    }
    // The workspace is now Terminating — concurrent create-path operations
    // will be rejected by resolve_workspace + ensure_active.
    let mut blocking = Vec::new();
    for (object_type, label) in [
    (Sandbox::object_type(), "sandbox"),
    (Provider::object_type(), "provider"),
    (StoredProviderProfile::object_type(), "provider profile"),
    (ServiceEndpoint::object_type(), "service"),
    (SshSession::object_type(), "ssh session"),
    (
    super::policy::SANDBOX_SETTINGS_OBJECT_TYPE,
    "sandbox settings",
    ),
    (POLICY_OBJECT_TYPE, "sandbox policy"),
    (DRAFT_CHUNK_OBJECT_TYPE, "draft policy chunk"),
    (
    StoredProviderCredentialRefreshState::object_type(),
    "credential refresh state",
    ),
    ] {
    let records = state
    .store
    .list(object_type, &name, 1, 0)
    .await
    .map_err(|e| Status::internal(format!("resource check failed: {e}")))?;
    if !records.is_empty() {
    blocking.push(label);
    }
    }
    if !blocking.is_empty() {
    return Err(Status::failed_precondition(format!(
    "workspace '{}' still contains resources: {}",
    name,
    blocking.join(", ")
    )));
    }
    // Cascade-delete non-blocking resources before the final CAS delete.
    // This is safe without a transaction: the workspace is Terminating, so
    // ensure_active rejects new resource creation. If delete_if conflicts
    // below, the retry will find no routes/members to delete and succeed.
    state
    .store
    .delete_all_in_workspace(InferenceRoute::object_type(), &name)
    .await
    .map_err(|e| Status::internal(format!("delete inference routes failed: {e}")))?;
    state
    .store
    .delete_all_in_workspace(WorkspaceMember::object_type(), &name)
    .await
    .map_err(|e| Status::internal(format!("delete workspace members failed: {e}")))?;
    // Keep the terminating workspace durable until platform cleanup has been
    // accepted. A failed cleanup can then be retried through this same path.
    state.compute.delete_workspace(&name).await.map_err(|e| {
    Status::new(
    e.code(),
    format!("delete workspace platform resources failed: {e}"),
    )
    })?;
    let deleted = state
    .store
    .delete_if(Workspace::object_type(), &ws_id, delete_version)
    .await
    .map_err(|e| {
    if matches!(e, crate::persistence::PersistenceError::Conflict { .. }) {
    Status::aborted("workspace was concurrently modified, please retry")
    } else {
    Status::internal(format!("delete workspace failed: {e}"))
    }
    })?;
    Ok(Response::new(DeleteWorkspaceResponse { deleted }))
    }

C. Original foreground cleanup failure, not syscall-traced

An earlier full integration run completed both stress assertion sets, but failed both real cases during strict cleanup. Mirror workspace deletion returned:

fetch workspace failed: database error: error returned from database: (code: 5) database is locked

The remote workspace deletion reported remaining sandbox, SSH-session, and policy resources. Before stopping the gateway, inventory showed two Ready sandboxes and their Active/Terminating test workspaces. Their sandbox deletions had failed before the workspace deletion. This run captured gateway/CLI logs but not syscall or parameterized SQL DEBUG traces, so it must not be presented as the same traced event as A.

Controlled reductions and limits

  • A short CLI-only flow with 37 actual CreateSshSession RPCs cleaned up successfully.
  • Adding 90 seconds of idle time also succeeded.
  • Adding enough actual SSH executions to reach 615 session RPCs also succeeded, without slow queries. Session volume alone did not reproduce the database failure.
  • Generating extra ssh-config files does not create sessions; only actual SSH ProxyCommand execution does. A config-generation-only run exposed B, not a session-volume failure.
  • An exact mirror-only integration run passed strict cleanup but still showed six slow database reads.
  • The full traced run in A had 128 stress workflows, 64 command executions, 16 intentional command failures, and 144 verified data files plus the mirror count/ledger. Its task sandboxes/workspaces, nested containers, and temporary directories were absent after automatic cleanup; the default workspace remained.

No WAL, busy-timeout, pool, schema, retry, or production-policy changes were used to make these tests pass. Raw databases, SSH configurations, session tokens, and unredacted logs will not be attached.

Current-source check

The relevant compute functions are byte-identical in v0.0.115: delete_sandbox_inner, cleanup_local_state_if_sandbox_absent, cleanup_sandbox_ssh_sessions, cleanup_sandbox_owned_records, and apply_deleted_locked. SQLite connection setup is also unchanged. This is source evidence only; v0.0.115 has not been live-tested for this report.

Exact test and diagnostic command

The reviewed test candidate is now published at OpenClaw commit 6953ce5; the full test and stress helper include the strict cleanup oracle. The held PR evidence records both the original failure and later traced pass. This is a full integration reproduction, not a reduced deterministic reproducer.

In an isolated Linux container with Docker, Node, pnpm, SSH, and the pinned OpenShell CLI/gateway installed, the tested setup uses a task-owned nested dockerd --storage-driver=vfs, a dedicated Docker network, and an explicit mTLS gateway. Generate certificates with openshell-gateway generate-certs; use the same OPENSHELL_LOCAL_TLS_DIR for certificate generation, the server, and openshell gateway add --local. The gateway config explicitly enables mtls_auth, uses that network and namespace for the Docker driver, points its guest endpoint at https://host.openshell.internal:17680, and supplies the generated guest CA/client certificate/key. The supervisor image is pinned to ghcr.io/nvidia/openshell/supervisor:8d67250a5d17348eb96c4fa46226b06d8041f2ba. No installer-managed gateway is running alongside it.

The source was installed with frozen dependencies and built before running the focused E2E:

git checkout 6953ce5cd4f77df0eef5c9fa8ebfe2b003f43caf
pnpm install --frozen-lockfile
OPENCLAW_BUILD_PRIVATE_QA=1 pnpm build

# Start only the isolated gateway with its existing generated TLS files/config.
# Set RUST_LOG=info,sqlx::query=debug on that gateway process for diagnostic SQL.
# Register/select it using the same isolated XDG_CONFIG_HOME and TLS directory.
openshell gateway select cleanup-proof
openshell --gateway cleanup-proof whoami

OPENCLAW_E2E_OPENSHELL_CONFIG_HOME="$XDG_CONFIG_HOME" \
OPENCLAW_E2E_WORKERS=1 OPENCLAW_VITEST_MAX_WORKERS=1 \
OPENCLAW_E2E_SKIP_BUILD=1 OPENCLAW_E2E_VERBOSE=1 \
pnpm test:e2e:openshell

# Before stopping the gateway, distinguish task resources from the default workspace.
openshell --gateway cleanup-proof sandbox list --all-workspaces -o json
openshell --gateway cleanup-proof workspace list -o json
docker ps -a

OPENCLAW_E2E_SKIP_BUILD=1 uses the successful preceding build; it does not skip either real test. Both tests must run. Capture the test exit and automatic inventory before any explicit recovery or environment teardown. For lock diagnostics we attached strace -f -ttt -T -yy -e trace=fcntl,fsync,fdatasync to only the gateway process; no read/write payloads or bound SQL values were collected.

Activity

  1. self-assigned this
    on Aug 28, 2026
  2. drew commented on Aug 28, 2026

    @drew
    Collaborator

    🏗️ build-plan

    Issue 2999: Cleanup contention and terminal deletion

    Objective

    Address both independently observed failures from #2999 without
    changing the compute-driver deletion contract or making speculative SQLite
    configuration changes.

    Deliver the work as two independent pull requests based on main:

    1. Batch persistence deletes during SSH-session cleanup so sandbox teardown does
      not create hundreds of individual SQLite write transactions.
    2. Make CLI sandbox deletion terminal by default and expose the same terminal
      wait capability in the Go SDK, while retaining an explicit asynchronous path.

    Neither pull request should auto-close the issue on its own. Use Part of #2999
    until both changes merge, then let a maintainer close the issue or update the
    second PR to close it.

    PR 1: Batch SSH-session cleanup writes

    Proposed title: fix(gateway): batch SSH session cleanup writes

    Issue type: fix

    Complexity: Medium

    Confidence: High that this removes the observed write burst; medium that it
    eliminates every reported SQLite read failure because the traced failures did
    not record their underlying SQLite error text.

    Summary

    Add a bounded, set-based persistence delete operation and use it wherever SSH
    session cleanup currently awaits one delete per record. Preserve backend-neutral
    behavior for SQLite and PostgreSQL, paginate record discovery, and emit one
    aggregate cleanup diagnostic instead of hundreds of delete spans.

    Do not enable WAL, change the pool size, or increase the busy timeout in this
    PR. The issue proves the serial write burst but does not isolate those broader
    database settings as the cause of every failed read.

    Scope

    • crates/openshell-server/src/persistence/mod.rs
      • Add a traced delete_many(object_type, ids) store operation.
      • Define an explicit maximum batch size at the caller or persistence boundary
        so SQLite bind-variable limits cannot be exceeded.
    • crates/openshell-server/src/persistence/sqlite.rs
      • Implement one parameterized DELETE ... WHERE object_type = ? AND id IN (...)
        statement per non-empty batch using bound parameters.
    • crates/openshell-server/src/persistence/postgres.rs
      • Implement equivalent bound, set-based deletion.
    • crates/openshell-server/src/compute/mod.rs
      • Paginate all workspace SSH-session records without deleting while offsets
        are being traversed.
      • Decode and collect only records owned by the target sandbox.
      • Delete matching IDs in bounded batches and report aggregate count/duration.
      • Preserve idempotency and unrelated sessions.
    • crates/openshell-server/src/ssh_sessions.rs
      • Batch expired/revoked reaper deletes through the same persistence API so a
        reaper sweep cannot recreate the same write pattern.
    • crates/openshell-server/src/persistence/tests.rs
      • Cover empty, mixed-type, partial-match, and multi-batch deletion behavior.
    • architecture/compute-runtimes.md
      • Briefly record that watcher-owned sandbox cleanup batches owned-record
        deletion; keep the architecture text focused on the stable lifecycle
        invariant rather than SQL details.

    Implementation steps

    1. Add backend-neutral Store::delete_many dispatch and backend implementations.
    2. Add persistence tests proving empty input is a no-op, IDs are constrained by
      object type, missing IDs are harmless, and the affected-row count is correct.
    3. Refactor cleanup_sandbox_ssh_sessions to paginate discovery, collect IDs,
      and delete in bounded batches only after discovery completes.
    4. Add compute tests with more than one list page of interleaved owned and
      unrelated sessions. Verify all owned sessions disappear and unrelated
      sessions remain.
    5. Refactor the session reaper to collect and batch expired/revoked IDs, and
      retain per-sweep aggregate logging.
    6. Add reaper tests for mixed live, expired, and revoked sessions across a batch
      boundary.
    7. Update the compute-runtime architecture invariant.

    Test plan

    • Unit:
      • cargo test -p openshell-server persistence::tests
      • Focused compute cleanup tests.
      • Focused SSH reaper tests.
    • Integration: Existing store tests exercise real SQLite. PostgreSQL is
      compile-checked; no dedicated PostgreSQL service fixture is required for this
      localized query.
    • E2E: Run mise run e2e:docker because sandbox lifecycle cleanup changes.
    • Required branch checks: mise run pre-commit, mise run test, and
      mise run ci before opening the draft PR. Record any environment-dependent
      E2E limitation rather than silently omitting it.

    Risks and mitigations

    • SQLite has a finite bind-variable limit. Keep batches deliberately bounded.
    • Discovery pagination must finish before deletion begins; deleting between
      offset-based pages would skip records.
    • A multi-batch cleanup can partially succeed if a later statement fails. This
      is no worse than the current per-row behavior and remains idempotent on retry.
    • Protobuf decode failures currently leave unknown records untouched. Preserve
      that behavior and include skipped/decode-failure counts in aggregate
      diagnostics without exposing tokens or payloads.
    • This removes the demonstrated write amplification but may not eliminate an
      independent filesystem or SQLite connection-level issue. Keep diagnostics
      sufficient to distinguish any residual failures.

    Compatibility

    • No protobuf, gateway configuration, Helm, or SDK changes.
    • No migration or on-disk schema change.
    • No SELinux/AppArmor-sensitive process, /proc, binary-execution, or IPC
      behavior changes.
    • No docs/reference/gateway-config.mdx update is required.

    PR 2: Terminal CLI deletion and Go wait support

    Proposed title: fix(cli): wait for terminal sandbox deletion

    Issue type: fix

    Complexity: Medium

    Confidence: High. The gateway and three SDKs already establish GetSandbox
    returning NOT_FOUND as the supported terminal signal.

    Summary

    Keep DeleteSandbox as an acknowledgement-oriented RPC because compute drivers,
    especially Kubernetes-style drivers, may accept deletion before physical
    removal. Change the CLI to wait for the durable sandbox record to disappear by
    default before printing Deleted sandbox, with --no-wait as an explicit
    compatibility/latency opt-out. Add Go WaitDeleted polling and clarify the raw
    RPC and client documentation.

    The default CLI path then composes safely with an immediately following
    workspace delete; low-level clients retain control over acknowledgement-only
    behavior.

    Scope

    • crates/openshell-cli/src/main.rs
      • Add sandbox delete --no-wait.
      • Add --timeout <SECONDS> for terminal waiting; use a documented finite
        default and define 0 as no timeout if consistent with existing CLI flags.
    • crates/openshell-cli/src/run.rs
      • After an acknowledged delete, poll GetSandbox until gRPC NOT_FOUND.
      • Use bounded backoff and timeout/cancellation-safe behavior.
      • Print Deleted sandbox only after terminal disappearance.
      • In --no-wait mode print acknowledgement-oriented wording rather than a
        terminal claim.
      • Ensure automatic sandbox create --no-keep cleanup uses terminal waiting.
    • crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs
      • Make the test gateway model acknowledged-then-terminal deletion.
      • Cover default wait, delayed disappearance, --no-wait, and timeout errors.
    • sdk/go/openshell/v1/sandbox.go
      • Add WaitDeleted(ctx, workspace, name, opts ...WaitOptions) error to
        SandboxInterface.
      • Clarify that Delete acknowledges deletion but does not guarantee terminal
        durable cleanup.
    • sdk/go/openshell/v1/sandbox_client.go
      • Poll Get until IsNotFound, honoring context cancellation/deadlines and
        the existing WaitOptions.PollInterval.
    • sdk/go/openshell/v1/fake/sandbox.go
      • Implement matching fake-client semantics.
    • sdk/go/openshell/v1/sandbox_client_test.go
      • Cover already absent, eventual absence, transient/non-NotFound error, custom
        polling interval, cancellation, and deadline behavior.
    • sdk/go/docs/src/api/sandboxes.md
      • Document acknowledgement versus terminal completion and show Delete
        followed by WaitDeleted with a context timeout.
    • proto/openshell.proto
      • Clarify the DeleteSandboxResponse.deleted contract. Regenerate committed
        Go protobuf bindings with the repository task if the comment is propagated
        into generated API docs.
    • docs/sandboxes/manage-sandboxes.mdx
      • Document terminal-by-default CLI behavior, --no-wait, timeout behavior,
        and safe workspace teardown composition.
    • architecture/compute-runtimes.md
      • No semantic redesign is needed; adjust wording only if necessary to link
        the existing watcher-finalized contract to client terminal waiting.
    • .agents/skills/openshell-cli/SKILL.md
      • Update delete examples and semantics because the CLI workflow changes.

    Implementation steps

    1. Add a reusable CLI wait helper that treats only NOT_FOUND as success,
      propagates other gRPC failures, and returns an actionable timeout error.
    2. Wire terminal waiting into sandbox delete by default and add the explicit
      asynchronous opt-out and timeout flags.
    3. Update CLI lifecycle integration fixtures and add deterministic delayed-delete
      coverage without wall-clock-heavy sleeps.
    4. Add WaitDeleted to the Go interface, real client, and fake client using the
      existing wait options and typed context errors.
    5. Add Go tests for success and failure paths.
    6. Clarify protobuf, CLI, Go SDK, published docs, and architecture wording.
    7. Update the openshell-cli skill, then run the sync-agent-infra
      maintenance map and resolve any related drift it identifies.

    Test plan

    • CLI unit/integration:
      • Argument parsing for default wait, --no-wait, and timeout.
      • cargo test -p openshell-cli including lifecycle integration coverage.
    • Go SDK: mise run go:ci.
    • Generated contracts: Run the appropriate proto generation/check task if
      proto/openshell.proto changes.
    • E2E: Run the focused Docker sandbox lifecycle path when available to prove
      sandbox delete followed immediately by workspace delete succeeds. No new
      broad E2E fixture is required if existing lifecycle coverage can express it.
    • Required branch checks: mise run pre-commit, mise run test, and
      mise run ci before opening the draft PR.

    Risks and mitigations

    • Terminal-by-default CLI behavior can increase command latency. Preserve
      acknowledgement-only behavior through --no-wait and document it.
    • A timeout does not cancel an already accepted backend deletion. The error must
      say deletion continues and only terminal observation timed out.
    • Multiple-name/--all deletion should not accidentally multiply a global
      timeout without clear semantics. Define the timeout per sandbox and keep
      output attributable to each name.
    • NOT_FOUND is terminal only after a successful delete acknowledgement for the
      stable requested sandbox. Do not treat initial lookup ambiguity as success in
      unrelated code paths.
    • Go interface expansion can break third-party mocks that implement
      SandboxInterface. This is an additive SDK API but a source compatibility
      change for implementers; call it out in release notes/PR description.
    • Do not make the server RPC block on physical backend removal; that would add
      timeout, watcher-loss, HA, and cancellation semantics outside this issue.

    Compatibility

    • Raw gRPC and Go Delete remain acknowledgement-oriented.
    • CLI default behavior changes intentionally; --no-wait preserves the old
      latency/acknowledgement behavior.
    • No gateway TOML, driver config, Helm, or database changes.
    • No SELinux/AppArmor-sensitive process or filesystem behavior changes.
    • No docs/reference/gateway-config.mdx update is required.

    Delivery order

    The PRs are independent and both target main.

    • PR 1 reduces the cleanup latency/contention that PR 2 may wait on.
    • PR 2 remains correct without PR 1 because it observes the existing terminal
      state and reports timeouts explicitly.
    • Open both as drafts and cross-link them in their Related Issue sections.
    • Do not apply state:accepted, roadmap, or agent:* labels; those remain
      human-owned. A maintainer must accept the issue before the drafts are promoted
      for normal review/merge.
  3. drew commented on Aug 28, 2026

    @drew
    Collaborator

    🏗️ build-from-issue-agent

    Draft PR 1: gateway cleanup batching

    Opened #3000 to address the gateway persistence side of this issue.

    The draft adds bounded, set-based SSH-session cleanup for SQLite and Postgres, paginates discovery before mutation, removes token-bearing per-session reaper logs, and adds regression/concurrency coverage.

    The issue remains state:triage-needed. A maintainer still needs to accept it before the draft is marked ready or merged. PR 2 will address terminal deletion behavior in the CLI and Go SDK.

  4. drew commented on Aug 28, 2026

    @drew
    Collaborator

    🏗️ build-from-issue-agent

    Implementation Complete — PR 2 of 2

    PR: #3001

    What was built

    The CLI now waits for terminal sandbox absence by default, with an explicit --no-wait opt-out and bounded timeout. The Go SDK keeps Delete acknowledgment-only and adds WaitDeleted for callers that need durable absence.

    Together with #3000, this completes the planned two-draft-PR implementation for #2999:

    1. fix(gateway): batch SSH session cleanup writes #3000 batches SSH-session cleanup persistence writes.
    2. fix(cli): wait for terminal sandbox deletion #3001 exposes terminal deletion semantics for composable CLI and Go SDK workflows.

    Tests

    • Unit/integration: CLI parser and mock lifecycle coverage; Go delete/wait coverage
    • Full Rust suite: passed
    • Docker E2E: 9 lifecycle tests passed across workspace_lifecycle and sandbox_lifecycle
    • Go build/lint/docs/proto and affected package tests: passed
    • Full Go CI caveat: three unrelated gateway discovery tests see this host's real /etc/openshell/gateways/default entry and assume the system gateway directory is empty

    Docs updated

    • Sandbox management guide
    • Go sandbox API guide
    • Compute runtime architecture
    • OpenShell CLI skill and command reference
    • Protobuf deletion contract comments

    The issue remains state:triage-needed. No lifecycle or agent-workflow labels were changed; maintainer acceptance is still required before these drafts should be marked ready or merged.

  5. removed
    state:triage-neededOpened without agent diagnostics and needs triage
    on Aug 28, 2026
  6. drew commented on Aug 28, 2026

    @drew
    Collaborator

    🏗️ build-from-issue-agent

    Exact reproduction and fix verification

    I reran the reporter fixture from OpenClaw commit 6953ce5cd4f77df0eef5c9fa8ebfe2b003f43caf with its required private-QA build, one E2E worker, and a real Docker-backed OpenShell gateway. Gateway SQL tracing was enabled so the cleanup shape could be compared directly.

    Untouched main baseline

    • Result: 1 failed, 4 passed.
    • The mirror-stress case reproduced the user-visible race: all three sandbox delete commands returned, then immediate workspace delete failed because the workspace still contained a sandbox.
    • The gateway issued 1,151 individual SSH-session DELETE statements across the two cases.
    • The largest cleanup serialized 568 writes over about 8.49 seconds.
    • This host did not reproduce the reporter intermittent SQLite/INTERNAL read errors. It reproduced the serial-write mechanism and the workspace race, so the database claim remains scoped to contention-risk removal rather than proof of every reported read failure.

    PR #3000: bounded SSH cleanup

    • Result: 5 passed, 0 failed.
    • The same 1,151 sessions were removed with 13 bounded bulk DELETE statements and zero individual SSH-session DELETE statements.
    • The 568-session cleanup completed in 5 batches over about 51 ms, compared with about 8.49 seconds on main.
    • No SQLite or control-plane errors appeared.

    PR #3001: terminal CLI deletion

    • Result: 5 passed, 0 failed.
    • This run intentionally retained the old server path and still issued 1,151 individual SSH-session DELETE statements.
    • Both immediate workspace deletions succeeded because the CLI waited for GetSandbox = NOT_FOUND before returning.

    Temporary combined build

    • Result: 5 passed, 0 failed.
    • 13 bounded SSH cleanup statements, zero individual SSH cleanup statements.
    • No workspace blocker and no candidate SQLite/control-plane error.

    This verifies the two PRs independently address the two separable behaviors and work together. The issue lifecycle labels remain human-owned and were not changed.

  7. github-actions commented on Sep 11, 2026

    @github-actions

    This issue has had no activity for 14 days and is now marked stale. It may be closed in 7 days if there is no further activity. Comment or remove the state:stale label to keep it open.

  8. github-actions commented on Oct 14, 2026

    @github-actions

    Closing this issue after 14 days with no activity since it was marked stale. Reopen it if the work is still relevant.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

state:staleInactive item at risk of automatic closure.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions