fix(issue-45): MAM herdr servers start with restore-on-restart off; resume closes its own restored empty shell (v4.8.5) #48
No files matched your search
@@ -117,7 +117,7 @@ Why use `--herdr-session`?
|
||||
- By default, all skills target `default` herdr session socket — fine for single-workspace use.
|
||||
- By using an isolated session via `HERDR_SESSION_NAME` (or `--herdr-session`), your agent sessions are completely separated from your default user workspace, ensuring 0% interference — this is backed by a genuinely separate `herdr` session/socket, not merely a workspace label.
|
||||
- To deliberately tear down an *entire* isolated group at once (all its workspaces and agents), use `herdr session stop <HERDR_SESSION_NAME>` followed by `herdr session delete <HERDR_SESSION_NAME>` — this only affects that named session, never the default one.
|
||||
- An isolated server MAM starts does not relaunch saved agents when it restarts (#45): it runs with `HERDR_CONFIG_PATH=<your herdr config dir>/mam-server-config.toml`, your herdr config with `[session] resume_agents_on_restore = false`. Its saved panes come back as empty shells; resume closes the one of the session it resumes.
|
||||
- An isolated server MAM starts does not relaunch saved agents when it restarts (#45): it runs with `HERDR_CONFIG_PATH=<your herdr config dir>/mam-server-config.toml`, your herdr config with `[session] resume_agents_on_restore = false`. Its saved panes come back as empty shells; resume closes the one of the session it resumes. Building that file needs `tomllib` (Python 3.11+) in the `python3` on PATH; with an older `python3` (macOS ships 3.9) restore is still off, but the file holds only that key, so the MAM server runs without your other herdr settings and the bootstrap prints a warning saying so.
|
||||
- That file is rebuilt each time MAM starts the server, so a change to your herdr config reaches a MAM server only when it is next started; `herdr server reload-config` rereads the generated file. A server you start yourself (`herdr session attach`) does not get it.
|
||||
|
||||
---
|
||||
|
||||
+2
-1
@@ -46,7 +46,7 @@
|
||||
> **패치 (PATCH Release)**: 격리 herdr 서버가 에이전트를 정지하지 않은 채 꺼졌다 다시 뜨면, herdr 가 저장 레이아웃을 복원하며 에이전트를 자기 재개 명령으로(권한 플래그 없이, 지운 에이전트까지) 다시 띄웠다. herdr 0.9.1 의 `[session] resume_agents_on_restore = false` 로 MAM 이 띄우는 서버에서 이를 끈다(A). 끈 뒤에도 pane 은 빈 셸로 복원되므로, resume 이 그 세션 자신의 빈 셸을 닫는다(B). 이슈의 제안 2·4 는 범위 밖.
|
||||
|
||||
#### 🌟 주요 개선 사항
|
||||
1. **서버 설정 (`lib_py/herdr_config.py`)**: herdr 가 읽는 사용자 설정(`HERDR_CONFIG_PATH` → `XDG_CONFIG_HOME` → `~/.config`)에서 그 키만 false 로 바꾼 `mam-server-config.toml` 을 원본 옆에 만든다(상대 경로가 같게 풀린다). 사용자 파일은 고치지 않는다: 출력이 원본 자신(별칭 포함)이거나 같은 원본으로 만든 파일이 아니면 원본별 이름을 쓴다. 파싱 → 키 설정 → 직렬화하고, 결과가 원본 + 키와 정확히 같아야 한다(헤더·점 표기·인라인 표 모두). 쓴 파일은 그 herdr 의 `config check` 를 통과해야 한다. herdr 는 파싱·스키마 오류면 파일 전체를 버리고("using defaults") 복원을 켜기 때문이다. 통과하지 못하면(깨짐, 비-bool 값, 표가 아닌 `session`, UTF-8 아님) 키 하나만 있는 파일을 쓰고 경고한다. MAM pane 이 물려받은 생성 파일은 기록된 원본으로 거슬러 올라간다.
|
||||
1. **서버 설정 (`lib_py/herdr_config.py`)**: herdr 가 읽는 사용자 설정(`HERDR_CONFIG_PATH` → `XDG_CONFIG_HOME` → `~/.config`)에서 그 키만 false 로 바꾼 `mam-server-config.toml` 을 원본 옆에 만든다(상대 경로가 같게 풀린다). 사용자 파일은 고치지 않는다. 이 병합에는 `tomllib`(Python 3.11+)이 필요하다. PATH 의 `python3` 가 그보다 오래되면(macOS 기본 3.9) 복원은 꺼지지만 파일에 그 키만 담겨, MAM 서버는 사용자의 다른 herdr 설정 없이 뜨고 경고가 남는다: 출력이 원본 자신(별칭 포함)이거나 같은 원본으로 만든 파일이 아니면 원본별 이름을 쓴다. 파싱 → 키 설정 → 직렬화하고, 결과가 원본 + 키와 정확히 같아야 한다(헤더·점 표기·인라인 표 모두). 쓴 파일은 그 herdr 의 `config check` 를 통과해야 한다. herdr 는 파싱·스키마 오류면 파일 전체를 버리고("using defaults") 복원을 켜기 때문이다. 통과하지 못하면(깨짐, 비-bool 값, 표가 아닌 `session`, UTF-8 아님) 키 하나만 있는 파일을 쓰고 경고한다. MAM pane 이 물려받은 생성 파일은 기록된 원본으로 거슬러 올라간다.
|
||||
2. **shim bootstrap (`lib.sh` heredoc)**: 서버를 띄울 때마다 설정을 다시 만들어 `HERDR_CONFIG_PATH` 로 넘긴다. 못 만들면 경고하고 예전처럼(복원 켜짐) 띄운다. 기본 `default` 서버와 사용자가 직접 띄운 서버는 바꾸지 않는다.
|
||||
3. **복원된 빈 셸 (`lib_py/herdr_restore.py`, `resume_session.sh`)**: spawn 직전 한 번, `agent_session` 이 resume 하는 id 와 같고 live 에이전트가 없고 전경이 셸뿐인 pane 을 닫는다. 등록부는 보지 않는다(서버가 죽은 세션도 running 으로 남는다, Creator 이의 job 9f5f1ebe). `CP_PREFIX` 가 붙는 resume 이 재사용하지 못해 남던 빈 셸과 `$HOME` 으로 떨어진 빈 셸이 대상이다.
|
||||
4. **S11 (`tests/test_issue23_24_startup_path.py`)**: 생성 shim 이 우연히 바뀌지 않게 하는 비교 기준을 heredoc 을 의도적으로 바꾼 ed73f67 로 옮겼다.
|
||||
@@ -55,6 +55,7 @@
|
||||
- **실서버 테스트 (`tests/test_issue45_restore.py`, herdr 0.9.1)**: 짧은 일회용 HOME·세션과 가짜 claude 로, MAM bootstrap 경로에서 복원이 꺼짐(사용자 설정에 `[session]` 표가 있을 때 포함)과 같은 레이아웃을 일반 서버로 띄우면 `claude --resume` 이 뜨는 양성 대조, 닫기 조건, create → crash → resume 뒤 pane 하나만 남음을 확인한다. 수정을 끄면 A·B 테스트가 각각 실패함을 확인했다.
|
||||
- **단위 (`tests/test_issue45_herdr_config.py`)**: 합치기 12 가지 경우와 실제 `herdr config check`, 원본 경로 규칙, 쓰기·폴백.
|
||||
- **관련 스위트**: 새 두 파일 40 passed. issue9·shim contract·issue42·issue24 resume·uuid_target·issue21 prereqs·issue23_24 199 passed / 1 skipped(S11 기준 이동 후). tier1~4·issue21_phase2_loop_a 148 passed. 전체 스위트는 돌리지 않았다.
|
||||
- **Python 3.9 (병합 워크스페이스)**: `tests/test_issue45_herdr_config.py` 가 모듈 머리에서 `import tomllib` 을 해 3.11 미만에서는 스위트 전체가 수집 단계에서 멈췄다. import 를 선택적으로 바꾸고 결과를 파싱하거나 사용자 설정 유지를 기대하는 29 건만 `tomllib` 없으면 skip(3.9: 22 passed / 29 skipped, 3.11: 51 passed).
|
||||
- **리뷰**: 1회차 codex NOT PASS(R1 herdr 스키마 검증 없음, R2 점 표기·인라인 표에서 설정 유실, R3 원본과 출력 이름 충돌 시 덮어씀). 811b080 에서 수정. 2회차 codex NOT PASS(R3 의 폴백 경로는 같은 검사를 거치지 않음), 폴백도 같은 이름 선택을 거치도록 수정.
|
||||
|
||||
### 🚀 `v4.8.4` — `.claude/skills` 링크 검사가 두 로컬 배치를 받고 깨진 링크를 잡는다 (2026-10-04, PR #47)
|
||||
|
||||
@@ -13,9 +13,13 @@ import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
try: # Python 3.11+; on older Pythons herdr_config writes the key-only config
|
||||
import tomllib
|
||||
except ImportError: # pragma: no cover - depends on the interpreter
|
||||
tomllib = None
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
@@ -25,6 +29,12 @@ from lib_py import herdr_config as hc # noqa: E402
|
||||
|
||||
HERDR = shutil.which("herdr")
|
||||
|
||||
# Python < 3.11 has no tomllib: herdr_config then writes the key-only config
|
||||
# (restore still off, the user's other settings dropped, with a warning), so
|
||||
# the tests that parse a result or expect the user's settings kept need it.
|
||||
# test_without_tomllib_restore_is_still_off covers that fallback everywhere.
|
||||
needs_tomllib = pytest.mark.skipif(tomllib is None, reason="needs tomllib (Python 3.11+)")
|
||||
|
||||
# (name, user config text or None, expect the user's other settings kept, expect a warning)
|
||||
CASES = [
|
||||
("missing", None, True, False),
|
||||
@@ -58,6 +68,7 @@ def _without_key(d):
|
||||
return d
|
||||
|
||||
|
||||
@needs_tomllib
|
||||
@pytest.mark.parametrize("name,user,kept,warned", CASES, ids=[c[0] for c in CASES])
|
||||
def test_merge_always_parses_with_restore_off(name, user, kept, warned):
|
||||
text, warnings = hc.server_config_text(user)
|
||||
@@ -70,6 +81,7 @@ def test_merge_always_parses_with_restore_off(name, user, kept, warned):
|
||||
assert text == hc.MINIMAL
|
||||
|
||||
|
||||
@needs_tomllib
|
||||
def test_a_non_bool_restore_value_is_replaced():
|
||||
# herdr would reject the whole file for this; the key is ours to set anyway.
|
||||
text, warnings = hc.server_config_text('[session]\nresume_agents_on_restore = "no"\n')
|
||||
@@ -121,6 +133,7 @@ def test_source_is_herdr_config_path_unless_generated(tmp_path):
|
||||
str(tmp_path / "x" / "herdr" / "config.toml")
|
||||
|
||||
|
||||
@needs_tomllib
|
||||
def test_written_next_to_source_with_its_mode(tmp_path):
|
||||
cfg_dir = tmp_path / "home" / ".config" / "herdr"
|
||||
cfg_dir.mkdir(parents=True)
|
||||
@@ -140,6 +153,7 @@ def test_written_next_to_source_with_its_mode(tmp_path):
|
||||
assert [p.name for p in cfg_dir.iterdir() if p.name.endswith(".tmp")] == []
|
||||
|
||||
|
||||
@needs_tomllib
|
||||
def test_written_without_any_user_config(tmp_path):
|
||||
path, warnings = hc.write_server_config(_env(tmp_path))
|
||||
assert path == str(tmp_path / "home" / ".config" / "herdr" / hc.OUTPUT_NAME) and warnings == []
|
||||
@@ -147,6 +161,7 @@ def test_written_without_any_user_config(tmp_path):
|
||||
assert tomllib.loads(Path(path).read_text()) == {"session": {"resume_agents_on_restore": False}}
|
||||
|
||||
|
||||
@needs_tomllib
|
||||
def test_rewritten_on_every_call(tmp_path):
|
||||
cfg_dir = tmp_path / "home" / ".config" / "herdr"
|
||||
cfg_dir.mkdir(parents=True)
|
||||
@@ -157,6 +172,7 @@ def test_rewritten_on_every_call(tmp_path):
|
||||
assert tomllib.loads(Path(path).read_text())["ui"]["confirm_close"] is False
|
||||
|
||||
|
||||
@needs_tomllib
|
||||
@pytest.mark.skipif(os.geteuid() == 0, reason="root ignores directory permissions")
|
||||
def test_unwritable_config_dir_falls_back_to_mam_dir(tmp_path):
|
||||
cfg_dir = tmp_path / "home" / ".config" / "herdr"
|
||||
@@ -184,6 +200,7 @@ def _tag(source: Path) -> str:
|
||||
return hashlib.sha256(os.path.realpath(source).encode()).hexdigest()[:12]
|
||||
|
||||
|
||||
@needs_tomllib
|
||||
@pytest.mark.skipif(os.geteuid() == 0, reason="root ignores directory permissions")
|
||||
def test_a_foreign_fallback_file_is_kept(tmp_path):
|
||||
cfg_dir = tmp_path / "cfg"
|
||||
@@ -204,6 +221,7 @@ def test_a_foreign_fallback_file_is_kept(tmp_path):
|
||||
assert tomllib.loads(Path(path).read_text())["ui"]["confirm_close"] is False
|
||||
|
||||
|
||||
@needs_tomllib
|
||||
@pytest.mark.parametrize("alias", [False, True], ids=["same-path", "symlink-alias"])
|
||||
def test_the_fallback_is_never_the_source(tmp_path, alias):
|
||||
mam = tmp_path / ".mam"
|
||||
@@ -243,6 +261,7 @@ def test_no_write_when_every_name_is_taken(tmp_path):
|
||||
assert all(f.read_bytes() == b"# foreign\n" for f in taken)
|
||||
|
||||
|
||||
@needs_tomllib
|
||||
def test_cli_prints_the_path(tmp_path):
|
||||
env = dict(os.environ, HOME=str(tmp_path), PYTHONPATH=str(REPO_ROOT / ".agents" / "skills"))
|
||||
env.pop("HERDR_CONFIG_PATH", None)
|
||||
@@ -295,6 +314,7 @@ def _cli(tmp_path, source: Path, *extra) -> subprocess.CompletedProcess:
|
||||
(b"session = 1\n[ui]\nconfirm_close = false\n", False), # non-table session (R1)
|
||||
(b"[ui]\nconfirm_close = false\n# \xff\xfe\n", False), # not UTF-8 (R1)
|
||||
], ids=["valid", "schema-error", "session-scalar", "invalid-utf8"])
|
||||
@needs_tomllib
|
||||
def test_cli_always_hands_herdr_a_restore_off_config_it_accepts(tmp_path, body, kept):
|
||||
source = tmp_path / "custom.toml"
|
||||
source.write_bytes(body)
|
||||
@@ -312,6 +332,7 @@ def test_cli_always_hands_herdr_a_restore_off_config_it_accepts(tmp_path, body,
|
||||
assert source.read_bytes() == body
|
||||
|
||||
|
||||
@needs_tomllib
|
||||
@pytest.mark.skipif(not HERDR, reason="herdr is not installed")
|
||||
def test_a_config_herdr_rejects_is_never_left_in_place(tmp_path):
|
||||
# the merge itself is fine for tomllib; only herdr's schema says no
|
||||
@@ -324,6 +345,7 @@ def test_a_config_herdr_rejects_is_never_left_in_place(tmp_path):
|
||||
|
||||
# --- the user's file is never the output (review R3) -----------------------
|
||||
|
||||
@needs_tomllib
|
||||
def test_source_named_like_the_output_is_not_overwritten(tmp_path):
|
||||
source = tmp_path / hc.OUTPUT_NAME
|
||||
source.write_bytes(b"[ui]\nconfirm_close = false\n")
|
||||
|
||||
Reference in new issue
Block a user