Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ All notable changes to OCM are documented here.

## Unreleased

### Changed

- Make `ocm service restart <env>` restart immediately through OpenClaw's protocol-v1 recovery handoff so eligible interrupted sessions and subagents resume after startup, preserve the legacy direct-restart behavior with a warning when recovery is unavailable, keep `--force` as an explicit bypass for an unhealthy handoff, and avoid self-restart deadlocks.
- Keep persisted supervisor child specifications stable when only the invoking shell's ambient environment changes, preventing unrelated gateways from reloading during scoped OCM operations, and report the exact field names behind genuine reloads.

## 0.2.30 - 2026-07-23

### Added
Expand Down
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,10 +251,19 @@ OCM negotiates fresh-process restart support only when it executes an
`openclaw.mjs` entrypoint directly or through OCM's managed Node.js toolchain,
so the gateway PID is the process OCM owns. `ocm service status <env>` reports
`protocol v1` when OpenClaw can hand restart intent back to OCM atomically.
With that protocol, `ocm service restart <env>` asks OpenClaw to restart
immediately through its recovery handoff. OpenClaw records eligible active
sessions and subagents before exiting, OCM starts the replacement gateway, and
OpenClaw resumes that recoverable work after startup. This does not wait for an
in-flight turn to finish before replacing the gateway process.

Package-manager, shell, host-Node, and other wrapper-backed bindings run in
legacy compatibility mode without OCM's native service identity or detached
respawn; use `ocm service restart <env>` or bind a directly invoked OpenClaw
runtime for gateway-initiated fresh-process restarts.
respawn. `ocm service restart <env>` preserves their existing direct-supervisor
restart behavior and warns that in-flight work cannot be recovered. Bind a
directly invoked OpenClaw runtime to gain recovery-aware restarts. Use
`ocm service restart <env> --force` only to explicitly bypass a recovery
handoff that is advertised but unhealthy.

## Why not just run OpenClaw directly?

Expand Down
25 changes: 25 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,31 @@ ocm service stop mira
ocm service restart mira
```

Normal restart is gateway-aware when `ocm service status mira` reports restart
handoff `protocol v1`: OpenClaw records eligible active sessions and subagents,
hands the fresh-process restart back to OCM immediately, and resumes recoverable
work after the replacement gateway starts. The old gateway process does not wait
for an in-flight turn to finish.

When a binding cannot negotiate the restart handoff, OCM preserves the existing
direct-supervisor restart behavior and prints a warning that in-flight work may
have been interrupted. Existing restart commands therefore remain compatible.

Use the forced path only when a gateway advertises recovery support but is too
unhealthy to accept the restart handoff:

```bash
ocm service restart mira --force
```

Forced restart explicitly replaces the supervised child directly. It bypasses
OpenClaw's restart-recovery handoff and can lose in-flight work, so it is
intentionally an emergency override rather than a compatibility requirement.

Recovery is limited to work OpenClaw knows how to persist and resume. It does
not make arbitrary child processes or non-idempotent external side effects
transactional.

### Remove the service

```bash
Expand Down
16 changes: 13 additions & 3 deletions src/cli/render/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2125,17 +2125,27 @@ pub fn service_command_help(cmd: &str, action: &str) -> Option<String> {
),
"restart" => render_leaf(
"Restart an env under the background service",
"Restart one env under the shared OCM background service.",
vec![format!("{cmd} service restart <env> [--raw] [--json]")],
"Restart one env immediately through OpenClaw's recovery handoff so eligible interrupted work can resume after startup.",
vec![format!(
"{cmd} service restart <env> [--force] [--raw] [--json]"
)],
&[
(
"--force",
"Bypass OpenClaw restart recovery and replace the supervised child directly",
),
(
"--raw",
"Force plain line output instead of the TTY receipt view",
),
("--json", "Print the action summary as JSON"),
],
vec![format!("{cmd} service restart mira")],
&[],
&[
"The default restart does not wait for active work; OpenClaw records eligible interrupted work for recovery before exiting.",
"Gateways without recovery handoff support keep the legacy direct-restart behavior and emit a warning.",
"Use --force only to bypass a recovery handoff that is advertised but unhealthy.",
],
),
"uninstall" => render_leaf(
"Disable an env in the background service",
Expand Down
5 changes: 4 additions & 1 deletion src/cli/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,15 @@ impl Cli {
pub(super) fn handle_service_restart(&self, args: Vec<String>) -> Result<i32, String> {
let (args, json_flag, profile) =
self.consume_human_output_flags(args, "service restart")?;
let (args, force) = Self::consume_flag(args, "--force");
let Some(name) = args.first() else {
return Err("service restart requires <env>".to_string());
};
Self::assert_no_extra_args(&args[1..])?;

let summary = self.service_service().restart(name)?;
let summary = self
.service_service()
.restart_with_options(name, crate::service::ServiceRestartOptions { force })?;
if json_flag {
self.print_json(&summary)?;
return Ok(0);
Expand Down
9 changes: 6 additions & 3 deletions src/cli/upgrade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2677,9 +2677,12 @@ impl Cli {
}

if service.running {
let restart = self
.with_progress(format!("Restarting service for {env_name}"), || {
self.service_service().restart_locked(env_name)
let restart =
self.with_progress(format!("Restarting service for {env_name}"), || {
self.service_service().restart_locked_with_options(
env_name,
crate::service::ServiceRestartOptions { force: true },
)
})?;
let note = join_optional_warnings(
join_warnings(&restart.warnings),
Expand Down
196 changes: 194 additions & 2 deletions src/service/manage.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
use std::collections::BTreeMap;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::thread::sleep;
use std::time::{Duration, Instant};

#[cfg(unix)]
use std::os::unix::process::CommandExt;

use serde::Serialize;

use super::inspect::ServiceSummary;
use super::service_backend_support_error;
use crate::env::EnvironmentService;
use crate::env::{EnvironmentService, ResolvedExecution};
use crate::infra::shell::{build_openclaw_dev_source_env, build_openclaw_env};
use crate::managed_node::apply_path_prepend_to_environment;
use crate::store::{restore_environment_service_policy, set_environment_service_policy};
use crate::supervisor::{SupervisorService, sync_supervisor_if_present};

Expand All @@ -31,6 +37,11 @@ pub struct ServiceActionSummary {

pub type ServiceInstallSummary = ServiceActionSummary;

#[derive(Clone, Copy, Debug, Default)]
pub struct ServiceRestartOptions {
pub force: bool,
}

#[derive(Clone, Copy)]
enum ServiceSupervisorPolicy {
LeaveAsIs,
Expand Down Expand Up @@ -128,6 +139,7 @@ pub fn stop_service(

pub fn restart_service(
name: &str,
options: ServiceRestartOptions,
env: &BTreeMap<String, String>,
cwd: &Path,
) -> Result<ServiceActionSummary, String> {
Expand All @@ -143,6 +155,59 @@ pub fn restart_service(
return update_service(name, ServiceUpdate::Restart, env, cwd);
}

if options.force {
return restart_running_service_directly(
name,
before,
env,
cwd,
"forced supervisor restart bypassed OpenClaw restart recovery handoff",
);
}

if before.restart_handoff.as_deref() != Some("protocol-v1") {
return restart_running_service_directly(
name,
before,
env,
cwd,
"OpenClaw restart recovery handoff protocol v1 is unavailable; used the legacy direct supervisor restart path, so in-flight work may have been interrupted",
);
}

spawn_recovery_aware_restart(name, env, cwd)?;

if env.get("OCM_ACTIVE_ENV").map(String::as_str) == Some(name) {
let mut warnings = vec![
"recovery-aware restart was scheduled without waiting because the request originated inside the target gateway; OpenClaw will preserve and resume eligible interrupted work after restart"
.to_string(),
];
let summary = super::inspect::service_status_fast(name, env, cwd)?;
if !summary.running {
warnings
.push("gateway is already transitioning to its replacement process".to_string());
}
return Ok(service_action_summary("restart", summary, warnings));
}

let status = wait_for_restart_action_summary(name, before.child_pid, env, cwd)?;
let mut warnings = status.warnings;
if !status.observed_restart {
warnings.push(
"recovery-aware restart was accepted, but OCM did not observe a replacement gateway within 30 seconds; no direct supervisor restart was attempted"
.to_string(),
);
}
Ok(service_action_summary("restart", status.summary, warnings))
}

fn restart_running_service_directly(
name: &str,
before: ServiceSummary,
env: &BTreeMap<String, String>,
cwd: &Path,
restart_warning: &str,
) -> Result<ServiceActionSummary, String> {
let supervisor = SupervisorService::new(env, cwd);
let mut request_id = supervisor.request_child_restart(name)?;
let restart_result = wait_for_restart_action_summary(name, before.child_pid, env, cwd);
Expand Down Expand Up @@ -179,6 +244,7 @@ pub fn restart_service(
"restart completed, but failed to clear restart request: {clear_error}"
));
}
status.warnings.push(restart_warning.to_string());
Ok(service_action_summary(
"restart",
status.summary,
Expand All @@ -189,6 +255,132 @@ pub fn restart_service(
}
}

fn spawn_recovery_aware_restart(
name: &str,
env: &BTreeMap<String, String>,
cwd: &Path,
) -> Result<(), String> {
let args = vec![
"gateway".to_string(),
"restart".to_string(),
"--force".to_string(),
"--json".to_string(),
];
let resolved = EnvironmentService::new(env, cwd).resolve(name, None, None, &args)?;
let command = resolved_restart_command(resolved, env)?;
let mut process = Command::new(&command.program);
process
.args(&command.args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.env_clear()
.envs(&command.env)
.current_dir(&command.cwd);
#[cfg(unix)]
process.process_group(0);
let mut child = process
.spawn()
.map_err(|error| format!("failed to run \"{}\": {error}", command.program))?;

let deadline = Instant::now() + Duration::from_millis(500);
while Instant::now() < deadline {
match child.try_wait() {
Ok(Some(status)) if status.success() => return Ok(()),
Ok(Some(status)) => {
return Err(format!(
"OpenClaw rejected the recovery-aware restart for env \"{name}\" (exit code {}); no direct supervisor restart was attempted. Inspect the gateway logs or use \"ocm service restart {name} --force\" to bypass OpenClaw recovery handoff",
status.code().unwrap_or(1)
));
}
Ok(None) => sleep(Duration::from_millis(25)),
Err(error) => {
return Err(format!(
"failed to inspect the recovery-aware restart helper for env \"{name}\": {error}"
));
}
}
}
Ok(())
}

struct ResolvedRestartCommand {
program: String,
args: Vec<String>,
env: BTreeMap<String, String>,
cwd: PathBuf,
}

fn resolved_restart_command(
resolved: ResolvedExecution,
process_env: &BTreeMap<String, String>,
) -> Result<ResolvedRestartCommand, String> {
match resolved {
ResolvedExecution::Launcher {
env,
command,
run_dir,
..
} => {
let openclaw_env = build_openclaw_env(&env, process_env);
if cfg!(windows) {
Ok(ResolvedRestartCommand {
program: "cmd".to_string(),
args: vec!["/C".to_string(), command],
env: openclaw_env,
cwd: run_dir,
})
} else {
Ok(ResolvedRestartCommand {
program: "sh".to_string(),
args: vec!["-lc".to_string(), command],
env: openclaw_env,
cwd: run_dir,
})
}
}
ResolvedExecution::Runtime {
env,
program,
program_args,
path_prepend,
run_dir,
..
} => {
let mut openclaw_env = build_openclaw_env(&env, process_env);
apply_path_prepend_to_environment(&mut openclaw_env, path_prepend.as_deref())?;
Ok(ResolvedRestartCommand {
program,
args: program_args,
env: openclaw_env,
cwd: run_dir,
})
}
ResolvedExecution::Dev {
env,
program,
program_args,
run_dir,
..
}
| ResolvedExecution::SourceWatch {
env,
program,
program_args,
run_dir,
..
} => {
let openclaw_env = build_openclaw_dev_source_env(&env, process_env, &run_dir);
Ok(ResolvedRestartCommand {
program,
args: program_args,
env: openclaw_env,
cwd: run_dir,
})
}
}
}

pub fn uninstall_service(
name: &str,
env: &BTreeMap<String, String>,
Expand Down
Loading