NousResearch/hermes-agent

s6 image gateway state migration and auto-redirect

Cluster 34339 · gateway. Data as of

Nothing open on GitHub. Fix merged in #34829 on 1 Jun 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #34829. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #34829. That is separate from the fix event.
Still open
Nothing open on GitHub.

These items address missing gateway_state.json seeding when upgrading from tini-based to s6-overlay Docker images, and automatic redirection of legacy 'gateway run' commands to supervised mode in the s6 image.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #34829 fix(docker): seed s6 gateway state for legacy run cmd

    merged to main

    files: hermes_cli/container_boot.py, tests/hermes_cli/test_container_boot.py

    Show the diff
    • hermes_cli/container_boot.py
      @@ -24,7 +24,7 @@
       import os
       from dataclasses import dataclass
       from pathlib import Path
      -from typing import Literal
      +from typing import Literal, Sequence
       
       log = logging.getLogger(__name__)
       
      @@ -57,6 +57,7 @@ def reconcile_profile_gateways(
           hermes_home: Path,
           scandir: Path,
           dry_run: bool = False,
      +    container_argv: Sequence[str] | None = None,
       ) -> list[ReconcileAction]:
           """Recreate s6 service registrations for every persistent profile.
       
      @@ -82,6 +83,8 @@ def reconcile_profile_gateways(
                   directories are created at ``<scandir>/gateway-<profile>/``.
               dry_run: When True, walk and return the action list without
                   touching the filesystem. For tests and `--dry-run` debug.
      +        container_argv: Optional container PID 1 argv override. Production
      +            reads ``/proc/1/cmdline``; tests inject it directly.
       
           Returns:
               One :class:`ReconcileAction` per profile, in this order:
      @@ -93,8 +96,15 @@ def reconcile_profile_gateways(
           # populated the root profile dir. The slot exists so
           # ``hermes gateway start`` (no ``-p``) has somewhere to land;
           # auto-up only when the prior state was "running" (same rule as
      -    # named profiles).
      -    default_prior_state = _read_prior_state(hermes_home)
      +    # named profiles). If the container was launched with the legacy
      +    # `gateway run` command and no state exists yet, seed that intent
      +    # as `running` so the s6 reconciler preserves the pre-s6 behavior.
      +    legacy_default_state = _maybe_migrate_legacy_gateway_run_state(
      +        hermes_home,
      +        container_argv=container_argv,
      +        dry_run=dry_run,
      +    )
      +    default_prior_state = legacy_default_state or _read_prior_state(hermes_home)
           default_should_start = default_prior_state in _AUTOSTART_STATES
           if not dry_run:
               _cleanup_stale_runtime_files(hermes_home)
      @@ -147,6 +157,66 @@ def reconcile_profile_gateways(
           return actions
       
       
      +def _maybe_migrate_legacy_gateway_run_state(
      +    hermes_home: Path,
      +    *,
      +    container_argv: Sequence[str] | None,
      +    dry_run: bool,
      +) -> str | None:
      +    """Seed root gateway_state for pre-s6 `gateway run` containers.
      +
      +    The tini image let Docker users run the gateway as the container
      +    command (`docker run ... gateway run`). After the s6 migration,
      +    profile gateways are restored from persisted gateway_state.json; a
      +    legacy container with no state file would therefore register the
      +    default service down and never start. Only synthesize state when no
      +    root gateway_state.json exists so explicit stopped/failed states keep
      +    winning across restarts.
      +    """
      +    state_file = hermes_home / "gateway_state.json"
      +    if state_file.exists():
      +        return None
      +
      +    if os.environ.get("HERMES_GATEWAY_NO_SUPERVISE", "").lower() in ("1", "true", "yes"):
      +        return None
      +
      +    argv = tuple(container_argv) if container_argv is not None else _read_container_argv()
      +    if not _is_legacy_gateway_run_request(argv):
      +        return None
      +
      +    if not dry_run:
      +        import time
      +        state_file.write_text(json.dumps({
      +            "gateway_state": "running",
      +            "timestamp": int(time.time()),
      +            "migrated_from": "legacy-container-cmd",
      +        }) + "\n")
      +    return "running"
      +
      +
      +def _read_container_argv() -> tuple[str, ...]:
      +    """Best-effort read of the container PID 1 argv."""
      +    try:
      +        raw = Path("/proc/1/cmdline").read_bytes()
      +    except OSError:
      +        return ()
      +    return tuple(part.decode("utf-8", "replace") for part in raw.split(b"\0") if part)
      +
      +
      +def _is_legacy_gateway_run_request(argv: Sequence[str]) -> bool:
      +    """Return True for Docker commands equivalent to `gateway run`."""
      +    args = list(argv)
      +    if args and Path(args[0]).name == "init":
      +        args = args[1:]
      +    if args and args[0].endswith("main-wrapper.sh"):
      +        args = args[1:]
      +    if args and Path(args[0]).name == "hermes":
      +        args = args[1:]
      +    if "--no-supervise" in args:
      +        return False
      +    return len(args) >= 2 and args[0] == "gateway" and args[1] == "run"
      +
      +
       def _read_prior_state(profile_dir: Path) -> str | None:
           """Read gateway_state.json's ``gateway_state`` field, or None if
           missing or unparseable. Unparseable counts as "no prior state" so
    • tests/hermes_cli/test_container_boot.py
      @@ -484,6 +484,88 @@ def test_default_slot_autostarts_when_root_state_running(tmp_path: Path) -> None
           assert not (scandir / "gateway-default" / "down").exists()
       
       
      [redacted:email](
      +    "container_argv",
      +    [
      +        ("gateway", "run"),
      +        ("/init", "/opt/hermes/docker/main-wrapper.sh", "gateway", "run"),
      +    ],
      +)
      +def test_legacy_gateway_run_cmd_seeds_default_running_state(
      +    tmp_path: Path,
      +    container_argv: tuple[str, ...],
      +) -> None:
      +    """Pre-s6 Docker users often ran `gateway run` as the container
      +    command. With no persisted gateway_state.json yet, s6 reconciliation
      +    must migrate that legacy intent into a running default gateway slot."""
      +    scandir = tmp_path / "run-service"; scandir.mkdir()
      +
      +    actions = reconcile_profile_gateways(
      +        hermes_home=tmp_path,
      +        scandir=scandir,
      +        dry_run=False,
      +        container_argv=container_argv,
      +    )
      +
      +    default_action = next(a for a in actions if a.profile == "default")
      +    assert default_action.prior_state == "running"
      +    assert default_action.action == "started"
      +    assert not (scandir / "gateway-default" / "down").exists()
      +    state = json.loads((tmp_path / "gateway_state.json").read_text())
      +    assert state["gateway_state"] == "running"
      +    assert state["migrated_from"] == "legacy-container-cmd"
      +
      +
      [redacted:email](
      +    "container_argv",
      +    [
      +        ("gateway", "run", "--no-supervise"),
      +        ("/init", "/opt/hermes/docker/main-wrapper.sh", "gateway", "run", "--no-supervise"),
      +    ],
      +)
      +def test_legacy_gateway_run_no_supervise_does_not_seed_s6_state(
      +    tmp_path: Path,
      +    container_argv: tuple[str, ...],
      +) -> None:
      +    """`gateway run --no-supervise` is an explicit opt-out from s6 migration."""
      +    scandir = tmp_path / "run-service"; scandir.mkdir()
      +
      +    actions = reconcile_profile_gateways(
      +        hermes_home=tmp_path,
      +        scandir=scandir,
      +        dry_run=False,
      +        container_argv=container_argv,
      +    )
      +
      +    default_action = next(a for a in actions if a.profile == "default")
      +    assert default_action.prior_state is None
      +    assert default_action.action == "registered"
      +    assert (scandir / "gateway-default" / "down").exists()
      +    assert not (tmp_path / "gateway_state.json").exists()
      +
      +
      +def test_legacy_gateway_run_env_no_supervise_does_not_seed_s6_state(
      +    tmp_path: Path,
      +    monkeypatch: pytest.MonkeyPatch,
      +) -> None:
      +    """Env opt-out matches the CLI `--no-supervise` flag."""
      +    scandir = tmp_path / "run-service"; scandir.mkdir()
      +    monkeypatch.setenv("HERMES_GATEWAY_NO_SUPERVISE", "1")
      +
      +    actions = reconcile_profile_gateways(
      +        hermes_home=tmp_path,
      +        scandir=scandir,
      +        dry_run=False,
      +        container_argv=("gateway", "run"),
      +    )
      +
      +    default_action = next(a for a in actions if a.profile == "default")
      +    assert default_action.prior_state is None
      +    assert default_action.action == "registered"
      +    assert (scandir / "gateway-default" / "down").exists()
      +    assert not (tmp_path / "gateway_state.json").exists()
      +
      +
       def test_default_slot_does_not_autostart_when_root_state_stopped(
           tmp_path: Path,
       ) -> None:
      @@ -491,12 +573,17 @@ def test_default_slot_does_not_autostart_when_root_state_stopped(
           _seed_default_root(tmp_path, state="stopped")
       
           actions = reconcile_profile_gateways(
      -        hermes_home=tmp_path, scandir=scandir, dry_run=False,
      +        hermes_home=tmp_path,
      +        scandir=scandir,
      +        dry_run=False,
      +        container_argv=("gateway", "run"),
           )
       
           default_action = next(a for a in actions if a.profile == "default")
           assert default_action.action == "registered"
           assert (scandir / "gateway-default" / "down").exists()
      +    state = json.loads((tmp_path / "gateway_state.json").read_text())
      +    assert state["gateway_state"] == "stopped"
       
       
       def test_default_slot_does_not_autostart_when_root_state_startup_failed(

1 PR closed without merging

From the close comments: done elsewhere 1

  • #35341 fix(docker): seed s6 gateway state for legacy gateway run containers · done elsewhere → dcbf62e, #34829, #34339
Closed issues · 1
  • Closed#34339Gateway not auto-starting after upgrade from tini-based to s6-overlay image — missing migration for gateway_state.json
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
gateway
Merged PRs
#34829
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
1 Jun 2026

For your agent

The same facts, typed.