Skip to content

Gateway restart leaves every sandbox in Error (StartFailed); --restart-policy always not honored #4910

Open

Description

@addozhang

User Story

As an operator restarting the local gateway (e.g. brew services restart openshell after a config change or upgrade), I want my sandboxes to come back — automatically per their restart policy, or at least be directly restartable — so that routine gateway maintenance doesn't take down every running workload.

Problem Statement

After a gateway restart, every sandbox transitions to Error:

Ready: False (StartFailed) - Failed to start sandbox during gateway startup:
Docker supervisor exited before becoming ready
  • No sandbox recovers on its own, including ones created with --restart-policy always (Restart count stays 0).
  • openshell sandbox start refuses from this state:
Error: × sandbox must be Stopped, Completed, or a failed main-process Error to start
  (current phase: Error); stop the sandbox before retrying start
  • The only recovery is a per-sandbox stop → start.

Impact / Why This Matters

Any gateway restart or upgrade is a fully destructive operation for all sandboxes, with per-sandbox manual recovery. --restart-policy always silently does not apply to this path, which is surprising given its name.

Acceptance Criteria

  • After a gateway restart, sandboxes with restart-policy: always return to Ready automatically, or at minimum sandbox start works directly from the post-restart state, or
  • the documentation clearly states that a gateway restart requires per-sandbox stop/start.

Reproduction Steps

OpenShell v0.1.5, docker compute driver:

openshell sandbox create --name repro-b1 --from <any image>                            # Ready
openshell sandbox create --name repro-b2 --from <any image> --restart-policy always    # Ready
brew services restart openshell        # gateway listener back in ~2s
openshell sandbox list                 # both sandboxes: Error
openshell sandbox get repro-b2
#   Phase: Error
#   Restart count: 0        <- policy=always never engaged

Recovery (works, ~6.5 s per sandbox; workspace and policy revisions fully preserved):

openshell sandbox stop repro-b2 && openshell sandbox start repro-b2   # back to Ready

Mechanism (from logs)

  • During gateway startup, all sandboxes are restored concurrently. Workload containers exit with code 1, logging:
Error: × sandbox boundary received SIGTERM before supervisor confirmation
  • The new supervisor's log tail ends with:
boundary terminated: boundary process leaf: sandbox session is terminal
  • Interpretation: only the same supervisor process can resume a frozen workload (by design), so after the gateway restart the old workload cannot be resumed; it is SIGTERMed, the new supervisor sees a terminal session and gives up with StartFailed. The restart-policy machinery never runs on this path.

Suggested UX (if applicable)

On gateway startup, either re-create the workload for sandboxes whose restart policy allows it, or leave them in a phase from which sandbox start works directly.

Environment

  • OpenShell v0.1.5 (Homebrew, macOS 26.6.2 Apple Silicon)
  • Docker compute driver; Docker provider: OrbStack 2.2.4 (Docker Engine 29.8.2). OrbStack is not in the support matrix, so we cannot fully rule out a provider-specific component in "supervisor exited before becoming ready" — but the SIGTERM-before-confirmation behavior looks driver-agnostic. Happy to retest on Docker Desktop if useful.
  • Reproduced across 4 independent gateway restarts with 2–4 sandboxes; restart policies tested: never (default) and always.

Logs

$ openshell sandbox get repro-b2
  Phase: Error
  Restart count: 0
  Conditions:
    - Ready: False (StartFailed) - Failed to start sandbox during gateway startup: Docker supervisor exited before becoming ready; log tail: … openshell_supervisor: Starting sandbox supervision … boundary terminated: boundary process leaf: sandbox session is terminal

$ docker logs <workload container>
Error: × sandbox boundary received SIGTERM before supervisor confirmation

Activity

  1. addozhang commented on Oct 25, 2026

    @addozhang
    Author

    After filing, we looked into the v0.1.5 source, and part of this behavior is documented design rather than a defect:

    • The workload dying across a gateway restart follows from the one-supervisor-per-sandbox design ("If the supervisor disconnects, the sandbox freezes the agent. Only the same supervisor process can reconnect and resume it. A new supervisor can't take over a running sandbox, even with valid credentials." — architecture docs).
    • The driver also anticipates this path: boundary_startup_termination_marker explicitly recognizes "sandbox boundary received SIGTERM before supervisor confirmation" (crates/openshell-driver-podman/src/watcher.rs, mirrored in the docker driver), and the readiness wait has a dedicated branch for "Docker supervisor exited before becoming ready".

    We're not contesting the non-resumability itself. The parts we'd still like to see addressed — or documented — are:

    1. --restart-policy always is not consulted on this path. The flag is documented as "Restart behavior after the canonical main process exits". A gateway restart does terminate the main process (SIGTERM before supervisor confirmation), yet Restart count stays 0: the gateway-startup restore reports StartFailed without evaluating the restart policy. If that's intentional, the flag/docs should state that the policy does not cover gateway restarts; if not, the restore path could fall back to the policy before parking the sandbox in Error.
    2. sandbox start is blocked from the post-restart Error phase ("sandbox must be Stopped, Completed, or a failed main-process Error to start … stop the sandbox before retrying start"). Either allowing start directly from this state, or documenting the required stop → start recovery, would remove the surprise for operators.

    Happy to reframe this issue to just those two points (or split them) if that helps triage.

  2. O96a commented on Oct 25, 2026

    @O96a

    Likely two separate failures stacked here, and the policy one is the easier to explain: restart policy is only ever evaluated by the supervisor loop that owns the sandbox, so if that loop dies with the gateway and nothing re-reads the persisted policy on startup, --restart-policy is dead config no matter what you pass. Worth confirming the policy field actually round-trips into the state store — if it isn't persisted, that alone explains "always not honored".

    The StartFailed is a different bug, and you need to know which of two shapes you have before touching policy code:

    openshell sandbox list -o json
    docker ps -a
    

    Run both right after the restart. If the sandbox record persists but the container is gone, the start path is trying to start something that isn't there — the reconciler needs a "runtime object missing → re-create from spec" branch. If the container is still alive and only the gateway process died, it's an adopt bug: the gateway is re-adopting a handle it no longer owns and failing on the stale handle.

    I haven't run this on macOS with brew services, so treat the second half as what I'd check first rather than a diagnosis.

  3. addozhang commented on Oct 26, 2026

    @addozhang
    Author

    Ran both checks on v0.1.5 (macOS, brew services, docker driver).

    Persistence: the policy field does round-trip. Same sandbox (--restart-policy always), immediately before and after brew services restart:

    $ openshell sandbox get lab-h1 -o json | grep -E 'restart_'
    "restart_count": 0
    "restart_policy": "always"        # before
    
    (gateway restart; listener back in ~2s; sandbox → Error)
    
    "restart_count": 0
    "restart_policy": "always"        # after — still present, still not acted on
    

    So "dead config" doesn't explain this one: the restored record carries the policy; the restore path just never consults it.

    Shape: docker ps -a right after the restart:

    openshell-default--lab-h1-<uuid>    Exited (1) 1 second ago    # workload: present, killed during restore
    openshell-default--lab-h2-<uuid>    Exited (1) 1 second ago
    # no supervisor containers remain: the restore-started supervisors exited before becoming healthy and were removed
    

    So it's neither shape exactly: the sandbox record persists, the workload container is present but freshly Exited (1) (the "boundary received SIGTERM before supervisor confirmation" exit), and the supervisor container is gone after failing readiness. sandbox start then refuses purely on the phase gate:

    Error: sandbox must be Stopped, Completed, or a failed main-process Error to start
      (current phase: Error); stop the sandbox before retrying start
    

    —the runtime objects a direct start would need (fresh supervisor + restarted workload) are exactly what the forced stop → start cycle creates anyway.

    That makes it closest to your adopt/state-machine shape: restore attempts adoption → the new supervisor can't resume the frozen workload (by design) → everything unwinds to Error → and the restart policy is never evaluated even though the field survived the restart in the record.

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