{"repo":"NousResearch/hermes-agent","public_id":"34339","display_label":"s6 image gateway state migration and auto-redirect","summary":"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.","subsystem":"gateway","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":34829,"merged_at":"2026-06-01T01:28:56+00:00"},"resolution":{"state":"resolved","by":"pr-34829"},"freshness":{"state":"verified","last_verified_at":"2026-06-01T01:28:56+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":34339,"pr_number":34829,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-01T01:28:56+00:00"}],"members":[{"kind":"issue","number":34339,"title":"Gateway not auto-starting after upgrade from tini-based to s6-overlay image — missing migration for gateway_state.json","url":null,"state":"closed"},{"kind":"pr","number":34829,"title":"fix(docker): seed s6 gateway state for legacy run cmd","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/container_boot.py","tests/hermes_cli/test_container_boot.py"],"fix_hunks":[{"file":"hermes_cli/container_boot.py","hunks":["@@ -24,7 +24,7 @@\n import os\n from dataclasses import dataclass\n from pathlib import Path\n-from typing import Literal\n+from typing import Literal, Sequence\n \n log = logging.getLogger(__name__)\n ","@@ -57,6 +57,7 @@ def reconcile_profile_gateways(\n     hermes_home: Path,\n     scandir: Path,\n     dry_run: bool = False,\n+    container_argv: Sequence[str] | None = None,\n ) -> list[ReconcileAction]:\n     \"\"\"Recreate s6 service registrations for every persistent profile.\n ","@@ -82,6 +83,8 @@ def reconcile_profile_gateways(\n             directories are created at ``<scandir>/gateway-<profile>/``.\n         dry_run: When True, walk and return the action list without\n             touching the filesystem. For tests and `--dry-run` debug.\n+        container_argv: Optional container PID 1 argv override. Production\n+            reads ``/proc/1/cmdline``; tests inject it directly.\n \n     Returns:\n         One :class:`ReconcileAction` per profile, in this order:","@@ -93,8 +96,15 @@ def reconcile_profile_gateways(\n     # populated the root profile dir. The slot exists so\n     # ``hermes gateway start`` (no ``-p``) has somewhere to land;\n     # auto-up only when the prior state was \"running\" (same rule as\n-    # named profiles).\n-    default_prior_state = _read_prior_state(hermes_home)\n+    # named profiles). If the container was launched with the legacy\n+    # `gateway run` command and no state exists yet, seed that intent\n+    # as `running` so the s6 reconciler preserves the pre-s6 behavior.\n+    legacy_default_state = _maybe_migrate_legacy_gateway_run_state(\n+        hermes_home,\n+        container_argv=container_argv,\n+        dry_run=dry_run,\n+    )\n+    default_prior_state = legacy_default_state or _read_prior_state(hermes_home)\n     default_should_start = default_prior_state in _AUTOSTART_STATES\n     if not dry_run:\n         _cleanup_stale_runtime_files(hermes_home)","@@ -147,6 +157,66 @@ def reconcile_profile_gateways(\n     return actions\n \n \n+def _maybe_migrate_legacy_gateway_run_state(\n+    hermes_home: Path,\n+    *,\n+    container_argv: Sequence[str] | None,\n+    dry_run: bool,\n+) -> str | None:\n+    \"\"\"Seed root gateway_state for pre-s6 `gateway run` containers.\n+\n+    The tini image let Docker users run the gateway as the container\n+    command (`docker run ... gateway run`). After the s6 migration,\n+    profile gateways are restored from persisted gateway_state.json; a\n+    legacy container with no state file would therefore register the\n+    default service down and never start. Only synthesize state when no\n+    root gateway_state.json exists so explicit stopped/failed states keep\n+    winning across restarts.\n+    \"\"\"\n+    state_file = hermes_home / \"gateway_state.json\"\n+    if state_file.exists():\n+        return None\n+\n+    if os.environ.get(\"HERMES_GATEWAY_NO_SUPERVISE\", \"\").lower() in (\"1\", \"true\", \"yes\"):\n+        return None\n+\n+    argv = tuple(container_argv) if container_argv is not None else _read_container_argv()\n+    if not _is_legacy_gateway_run_request(argv):\n+        return None\n+\n+    if not dry_run:\n+        import time\n+        state_file.write_text(json.dumps({\n+            \"gateway_state\": \"running\",\n+            \"timestamp\": int(time.time()),\n+            \"migrated_from\": \"legacy-container-cmd\",\n+        }) + \"\\n\")\n+    return \"running\"\n+\n+\n+def _read_container_argv() -> tuple[str, ...]:\n+    \"\"\"Best-effort read of the container PID 1 argv.\"\"\"\n+    try:\n+        raw = Path(\"/proc/1/cmdline\").read_bytes()\n+    except OSError:\n+        return ()\n+    return tuple(part.decode(\"utf-8\", \"replace\") for part in raw.split(b\"\\0\") if part)\n+\n+\n+def _is_legacy_gateway_run_request(argv: Sequence[str]) -> bool:\n+    \"\"\"Return True for Docker commands equivalent to `gateway run`.\"\"\"\n+    args = list(argv)\n+    if args and Path(args[0]).name == \"init\":\n+        args = args[1:]\n+    if args and args[0].endswith(\"main-wrapper.sh\"):\n+        args = args[1:]\n+    if args and Path(args[0]).name == \"hermes\":\n+        args = args[1:]\n+    if \"--no-supervise\" in args:\n+        return False\n+    return len(args) >= 2 and args[0] == \"gateway\" and args[1] == \"run\"\n+\n+\n def _read_prior_state(profile_dir: Path) -> str | None:\n     \"\"\"Read gateway_state.json's ``gateway_state`` field, or None if\n     missing or unparseable. Unparseable counts as \"no prior state\" so"]},{"file":"tests/hermes_cli/test_container_boot.py","hunks":["@@ -484,6 +484,88 @@ def test_default_slot_autostarts_when_root_state_running(tmp_path: Path) -> None\n     assert not (scandir / \"gateway-default\" / \"down\").exists()\n \n \n[redacted:email](\n+    \"container_argv\",\n+    [\n+        (\"gateway\", \"run\"),\n+        (\"/init\", \"/opt/hermes/docker/main-wrapper.sh\", \"gateway\", \"run\"),\n+    ],\n+)\n+def test_legacy_gateway_run_cmd_seeds_default_running_state(\n+    tmp_path: Path,\n+    container_argv: tuple[str, ...],\n+) -> None:\n+    \"\"\"Pre-s6 Docker users often ran `gateway run` as the container\n+    command. With no persisted gateway_state.json yet, s6 reconciliation\n+    must migrate that legacy intent into a running default gateway slot.\"\"\"\n+    scandir = tmp_path / \"run-service\"; scandir.mkdir()\n+\n+    actions = reconcile_profile_gateways(\n+        hermes_home=tmp_path,\n+        scandir=scandir,\n+        dry_run=False,\n+        container_argv=container_argv,\n+    )\n+\n+    default_action = next(a for a in actions if a.profile == \"default\")\n+    assert default_action.prior_state == \"running\"\n+    assert default_action.action == \"started\"\n+    assert not (scandir / \"gateway-default\" / \"down\").exists()\n+    state = json.loads((tmp_path / \"gateway_state.json\").read_text())\n+    assert state[\"gateway_state\"] == \"running\"\n+    assert state[\"migrated_from\"] == \"legacy-container-cmd\"\n+\n+\n[redacted:email](\n+    \"container_argv\",\n+    [\n+        (\"gateway\", \"run\", \"--no-supervise\"),\n+        (\"/init\", \"/opt/hermes/docker/main-wrapper.sh\", \"gateway\", \"run\", \"--no-supervise\"),\n+    ],\n+)\n+def test_legacy_gateway_run_no_supervise_does_not_seed_s6_state(\n+    tmp_path: Path,\n+    container_argv: tuple[str, ...],\n+) -> None:\n+    \"\"\"`gateway run --no-supervise` is an explicit opt-out from s6 migration.\"\"\"\n+    scandir = tmp_path / \"run-service\"; scandir.mkdir()\n+\n+    actions = reconcile_profile_gateways(\n+        hermes_home=tmp_path,\n+        scandir=scandir,\n+        dry_run=False,\n+        container_argv=container_argv,\n+    )\n+\n+    default_action = next(a for a in actions if a.profile == \"default\")\n+    assert default_action.prior_state is None\n+    assert default_action.action == \"registered\"\n+    assert (scandir / \"gateway-default\" / \"down\").exists()\n+    assert not (tmp_path / \"gateway_state.json\").exists()\n+\n+\n+def test_legacy_gateway_run_env_no_supervise_does_not_seed_s6_state(\n+    tmp_path: Path,\n+    monkeypatch: pytest.MonkeyPatch,\n+) -> None:\n+    \"\"\"Env opt-out matches the CLI `--no-supervise` flag.\"\"\"\n+    scandir = tmp_path / \"run-service\"; scandir.mkdir()\n+    monkeypatch.setenv(\"HERMES_GATEWAY_NO_SUPERVISE\", \"1\")\n+\n+    actions = reconcile_profile_gateways(\n+        hermes_home=tmp_path,\n+        scandir=scandir,\n+        dry_run=False,\n+        container_argv=(\"gateway\", \"run\"),\n+    )\n+\n+    default_action = next(a for a in actions if a.profile == \"default\")\n+    assert default_action.prior_state is None\n+    assert default_action.action == \"registered\"\n+    assert (scandir / \"gateway-default\" / \"down\").exists()\n+    assert not (tmp_path / \"gateway_state.json\").exists()\n+\n+\n def test_default_slot_does_not_autostart_when_root_state_stopped(\n     tmp_path: Path,\n ) -> None:","@@ -491,12 +573,17 @@ def test_default_slot_does_not_autostart_when_root_state_stopped(\n     _seed_default_root(tmp_path, state=\"stopped\")\n \n     actions = reconcile_profile_gateways(\n-        hermes_home=tmp_path, scandir=scandir, dry_run=False,\n+        hermes_home=tmp_path,\n+        scandir=scandir,\n+        dry_run=False,\n+        container_argv=(\"gateway\", \"run\"),\n     )\n \n     default_action = next(a for a in actions if a.profile == \"default\")\n     assert default_action.action == \"registered\"\n     assert (scandir / \"gateway-default\" / \"down\").exists()\n+    state = json.loads((tmp_path / \"gateway_state.json\").read_text())\n+    assert state[\"gateway_state\"] == \"stopped\"\n \n \n def test_default_slot_does_not_autostart_when_root_state_startup_failed(\n"]}]},{"kind":"pr","number":35341,"title":"fix(docker): seed s6 gateway state for legacy gateway run containers","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"dcbf62e26aafcc34ed68f8249ff97e2943f5c858","number":0},{"kind":"pr","value":"#34829","number":34829},{"kind":"pr","value":"#34339","number":34339}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}