Repository navigation
Control-plane read failures during sandbox SSH-session cleanup; delete acknowledgment can precede durable cleanup #2999
Description
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
vfsstorage 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 wasdelete; 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:
OpenShell/crates/openshell-server/src/compute/mod.rs
Lines 2850 to 2875 in 8d67250
.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()) OpenShell/crates/openshell-server/src/persistence/sqlite.rs
Lines 366 to 380 in 8d67250
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
OpenShell/crates/openshell-server/src/persistence/mod.rs
Lines 178 to 192 in 8d67250
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()); } OpenShell/crates/openshell-server/src/inference.rs
Lines 67 to 91 in 8d67250
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) } OpenShell/crates/openshell-server/src/inference.rs
Lines 1108 to 1128 in 8d67250
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:
OpenShell/crates/openshell-server/src/compute/mod.rs
Lines 1589 to 1607 in 8d67250
} }, ) .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", )); } OpenShell/crates/openshell-server/src/compute/mod.rs
Lines 2910 to 2924 in 8d67250
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(()) OpenShell/crates/openshell-server/src/grpc/workspace.rs
Lines 285 to 448 in 8d67250
} 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 -aOPENCLAW_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
drew commentedon Aug 28, 2026
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:not create hundreds of individual SQLite write transactions.
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 #2999until 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 writesIssue type:
fixComplexity: 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.rsdelete_many(object_type, ids)store operation.so SQLite bind-variable limits cannot be exceeded.
crates/openshell-server/src/persistence/sqlite.rsDELETE ... WHERE object_type = ? AND id IN (...)statement per non-empty batch using bound parameters.
crates/openshell-server/src/persistence/postgres.rscrates/openshell-server/src/compute/mod.rsare being traversed.
crates/openshell-server/src/ssh_sessions.rsreaper sweep cannot recreate the same write pattern.
crates/openshell-server/src/persistence/tests.rsarchitecture/compute-runtimes.mddeletion; keep the architecture text focused on the stable lifecycle
invariant rather than SQL details.
Implementation steps
Store::delete_manydispatch and backend implementations.object type, missing IDs are harmless, and the affected-row count is correct.
cleanup_sandbox_ssh_sessionsto paginate discovery, collect IDs,and delete in bounded batches only after discovery completes.
unrelated sessions. Verify all owned sessions disappear and unrelated
sessions remain.
retain per-sweep aggregate logging.
boundary.
Test plan
cargo test -p openshell-server persistence::testscompile-checked; no dedicated PostgreSQL service fixture is required for this
localized query.
mise run e2e:dockerbecause sandbox lifecycle cleanup changes.mise run pre-commit,mise run test, andmise run cibefore opening the draft PR. Record any environment-dependentE2E limitation rather than silently omitting it.
Risks and mitigations
offset-based pages would skip records.
is no worse than the current per-row behavior and remains idempotent on retry.
that behavior and include skipped/decode-failure counts in aggregate
diagnostics without exposing tokens or payloads.
independent filesystem or SQLite connection-level issue. Keep diagnostics
sufficient to distinguish any residual failures.
Compatibility
/proc, binary-execution, or IPCbehavior changes.
docs/reference/gateway-config.mdxupdate is required.PR 2: Terminal CLI deletion and Go wait support
Proposed title:
fix(cli): wait for terminal sandbox deletionIssue type:
fixComplexity: Medium
Confidence: High. The gateway and three SDKs already establish
GetSandboxreturning
NOT_FOUNDas the supported terminal signal.Summary
Keep
DeleteSandboxas 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-waitas an explicitcompatibility/latency opt-out. Add Go
WaitDeletedpolling and clarify the rawRPC and client documentation.
The default CLI path then composes safely with an immediately following
workspace delete; low-level clients retain control over acknowledgement-onlybehavior.
Scope
crates/openshell-cli/src/main.rssandbox delete --no-wait.--timeout <SECONDS>for terminal waiting; use a documented finitedefault and define
0as no timeout if consistent with existing CLI flags.crates/openshell-cli/src/run.rsGetSandboxuntil gRPCNOT_FOUND.Deleted sandboxonly after terminal disappearance.--no-waitmode print acknowledgement-oriented wording rather than aterminal claim.
sandbox create --no-keepcleanup uses terminal waiting.crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs--no-wait, and timeout errors.sdk/go/openshell/v1/sandbox.goWaitDeleted(ctx, workspace, name, opts ...WaitOptions) errortoSandboxInterface.Deleteacknowledges deletion but does not guarantee terminaldurable cleanup.
sdk/go/openshell/v1/sandbox_client.goGetuntilIsNotFound, honoring context cancellation/deadlines andthe existing
WaitOptions.PollInterval.sdk/go/openshell/v1/fake/sandbox.gosdk/go/openshell/v1/sandbox_client_test.gopolling interval, cancellation, and deadline behavior.
sdk/go/docs/src/api/sandboxes.mdDeletefollowed by
WaitDeletedwith a context timeout.proto/openshell.protoDeleteSandboxResponse.deletedcontract. Regenerate committedGo protobuf bindings with the repository task if the comment is propagated
into generated API docs.
docs/sandboxes/manage-sandboxes.mdx--no-wait, timeout behavior,and safe workspace teardown composition.
architecture/compute-runtimes.mdthe existing watcher-finalized contract to client terminal waiting.
.agents/skills/openshell-cli/SKILL.mdImplementation steps
NOT_FOUNDas success,propagates other gRPC failures, and returns an actionable timeout error.
sandbox deleteby default and add the explicitasynchronous opt-out and timeout flags.
coverage without wall-clock-heavy sleeps.
WaitDeletedto the Go interface, real client, and fake client using theexisting wait options and typed context errors.
openshell-cliskill, then run thesync-agent-inframaintenance map and resolve any related drift it identifies.
Test plan
--no-wait, and timeout.cargo test -p openshell-cliincluding lifecycle integration coverage.mise run go:ci.proto/openshell.protochanges.sandbox deletefollowed immediately byworkspace deletesucceeds. No newbroad E2E fixture is required if existing lifecycle coverage can express it.
mise run pre-commit,mise run test, andmise run cibefore opening the draft PR.Risks and mitigations
acknowledgement-only behavior through
--no-waitand document it.say deletion continues and only terminal observation timed out.
--alldeletion should not accidentally multiply a globaltimeout without clear semantics. Define the timeout per sandbox and keep
output attributable to each name.
NOT_FOUNDis terminal only after a successful delete acknowledgement for thestable requested sandbox. Do not treat initial lookup ambiguity as success in
unrelated code paths.
SandboxInterface. This is an additive SDK API but a source compatibilitychange for implementers; call it out in release notes/PR description.
timeout, watcher-loss, HA, and cancellation semantics outside this issue.
Compatibility
Deleteremain acknowledgement-oriented.--no-waitpreserves the oldlatency/acknowledgement behavior.
docs/reference/gateway-config.mdxupdate is required.Delivery order
The PRs are independent and both target
main.state and reports timeouts explicitly.
state:accepted, roadmap, oragent:*labels; those remainhuman-owned. A maintainer must accept the issue before the drafts are promoted
for normal review/merge.
drew commentedon Aug 28, 2026
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.drew commentedon Aug 28, 2026
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-waitopt-out and bounded timeout. The Go SDK keepsDeleteacknowledgment-only and addsWaitDeletedfor callers that need durable absence.Together with #3000, this completes the planned two-draft-PR implementation for #2999:
Tests
workspace_lifecycleandsandbox_lifecycle/etc/openshell/gateways/defaultentry and assume the system gateway directory is emptyDocs updated
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.drew commentedon Aug 28, 2026
Exact reproduction and fix verification
I reran the reporter fixture from OpenClaw commit
6953ce5cd4f77df0eef5c9fa8ebfe2b003f43cafwith 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
sandbox deletecommands returned, then immediateworkspace deletefailed because the workspace still contained a sandbox.INTERNALread 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
main.PR #3001: terminal CLI deletion
GetSandbox = NOT_FOUNDbefore returning.Temporary combined build
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.
fix(gateway): batch SSH session cleanup writes (#3000)
github-actions commentedon Sep 11, 2026
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.
fix(cli): wait for terminal sandbox deletion
github-actions commentedon Oct 14, 2026
Closing this issue after 14 days with no activity since it was marked stale. Reopen it if the work is still relevant.