#71 에서 "상태를 바꾸는 인터페이스는 반드시 스킬을 거친다" 를 원칙으로 정했습니다(시작은 create·resume, 종료는 stop). 지금 MAM 버전 업데이트는 스킬이 아니라 설치된 작업 공간의 .mam_deploy/update.sh(원본 deploy/update.sh)로 합니다.
#71 은 레지스트리 형식을 바꿉니다. kill_command·start_command 를 지우고, attach_command 를 새 형태로 바꾸고, 형식 버전을 추가합니다. 그래서 lib_py 에 마이그레이션 함수를 두고, 레지스트리를 읽을 때 옛 형식이면 한 번 실행합니다. 업데이트가 끝난 직후에 이 함수를 명시적으로 부르는 일은 이 이슈로 넘겼습니다.
지금의 update.sh
.mam.env(또는 .env)와 .mam 의 일부(agent-sessions.*, jobs/, delegate_job_logs/, install_manifest.txt, install_state·asset_hashes.txt·version.txt, skill-backups/)를 임시 폴더에 복사합니다.
remove.sh --force 로 기존 설치를 지웁니다. .agents/, 문서, .venv, .mam 이 지워집니다.
Gitea main 의 install.sh 를 받아(MAM_INSTALLER_URL 로 바꿀 수 있음) 다시 설치합니다.
백업을 되돌립니다. install_state 는 키를 합칩니다(#8). 실패하면 trap 이 전부 되돌립니다.
레지스트리 내용은 복사만 하고 변환하지는 않습니다.
문제
스킬이 아닙니다. 다른 스킬처럼 레지스트리·잡 상태를 확인하지 않고, 결과를 레지스트리에 남기지도 않습니다.
실행 중인 세션과 충돌할 수 있습니다. 모니터·에이전트·루프가 떠 있어도 .mam 을 지웠다가 되돌립니다. 그 사이 레지스트리나 잡 기록에 쓰인 내용은 사라질 수 있습니다. .venv·.agents/ 도 실행 중에 바뀝니다.
마이그레이션을 부르지 않습니다. 형식 변환은 #71 의 "읽을 때 한 번" 에만 기대게 됩니다.
백업 목록이 고정돼 있습니다. 목록에 없는 .mam 파일은 지워진 뒤 돌아오지 않습니다. 예를 들어 herdr_workspace_id 는 lib.sh:328 에서 씁니다. 무엇을 보존해야 하는지 확인이 필요합니다.
제안
multi-agent-mux-update 스킬을 만들고 update.sh 가 하던 일을 그 안으로 옮깁니다.
사전 확인 (변경 전, 실패하면 아무것도 바꾸지 않음)
실행 중인 루프(loop lock), 열린 잡, 실행 중인 에이전트 세션이 있으면 거부합니다. 강제 옵션을 둘지는 논의가 필요합니다.
모니터 데몬은 멈추고, 끝난 뒤 다시 띄울지 정합니다.
백업·제거·설치·복원: 지금 update.sh 의 동작을 유지합니다(.mam.env 보존, install_state 합치기, 실패 시 되돌리기). 보존 목록은 위 문제 4 를 확인해 정합니다.
## 배경
#71 에서 "상태를 바꾸는 인터페이스는 반드시 스킬을 거친다" 를 원칙으로 정했습니다(시작은 create·resume, 종료는 stop). 지금 MAM 버전 업데이트는 스킬이 아니라 설치된 작업 공간의 `.mam_deploy/update.sh`(원본 `deploy/update.sh`)로 합니다.
#71 은 레지스트리 형식을 바꿉니다. `kill_command`·`start_command` 를 지우고, `attach_command` 를 새 형태로 바꾸고, 형식 버전을 추가합니다. 그래서 `lib_py` 에 마이그레이션 함수를 두고, 레지스트리를 읽을 때 옛 형식이면 한 번 실행합니다. 업데이트가 끝난 직후에 이 함수를 명시적으로 부르는 일은 이 이슈로 넘겼습니다.
## 지금의 update.sh
1. `.mam.env`(또는 `.env`)와 `.mam` 의 일부(`agent-sessions.*`, `jobs/`, `delegate_job_logs/`, `install_manifest.txt`, `install_state`·`asset_hashes.txt`·`version.txt`, `skill-backups/`)를 임시 폴더에 복사합니다.
2. `remove.sh --force` 로 기존 설치를 지웁니다. `.agents/`, 문서, `.venv`, `.mam` 이 지워집니다.
3. Gitea main 의 `install.sh` 를 받아(`MAM_INSTALLER_URL` 로 바꿀 수 있음) 다시 설치합니다.
4. 백업을 되돌립니다. `install_state` 는 키를 합칩니다(#8). 실패하면 trap 이 전부 되돌립니다.
레지스트리 내용은 복사만 하고 변환하지는 않습니다.
## 문제
1. **스킬이 아닙니다.** 다른 스킬처럼 레지스트리·잡 상태를 확인하지 않고, 결과를 레지스트리에 남기지도 않습니다.
2. **실행 중인 세션과 충돌할 수 있습니다.** 모니터·에이전트·루프가 떠 있어도 `.mam` 을 지웠다가 되돌립니다. 그 사이 레지스트리나 잡 기록에 쓰인 내용은 사라질 수 있습니다. `.venv`·`.agents/` 도 실행 중에 바뀝니다.
3. **마이그레이션을 부르지 않습니다.** 형식 변환은 #71 의 "읽을 때 한 번" 에만 기대게 됩니다.
4. **백업 목록이 고정돼 있습니다.** 목록에 없는 `.mam` 파일은 지워진 뒤 돌아오지 않습니다. 예를 들어 `herdr_workspace_id` 는 `lib.sh:328` 에서 씁니다. 무엇을 보존해야 하는지 확인이 필요합니다.
## 제안
`multi-agent-mux-update` 스킬을 만들고 update.sh 가 하던 일을 그 안으로 옮깁니다.
1. **사전 확인 (변경 전, 실패하면 아무것도 바꾸지 않음)**
- 실행 중인 루프(loop lock), 열린 잡, 실행 중인 에이전트 세션이 있으면 거부합니다. 강제 옵션을 둘지는 논의가 필요합니다.
- 모니터 데몬은 멈추고, 끝난 뒤 다시 띄울지 정합니다.
2. **백업·제거·설치·복원**: 지금 update.sh 의 동작을 유지합니다(`.mam.env` 보존, `install_state` 합치기, 실패 시 되돌리기). 보존 목록은 위 문제 4 를 확인해 정합니다.
3. **마이그레이션**
- 설치가 끝난 뒤, **새로 설치된 코드**의 마이그레이션 함수(#71)를 부릅니다.
- 업데이트 중에 `.agents/` 가 바뀌므로, 옛 코드가 실행되지 않게 순서를 지킵니다.
- 결과(이전·이후 형식 버전, 바뀐 행 수)를 출력합니다.
4. **기존 진입점**
- `.mam_deploy/update.sh` 는 스킬을 부르는 얇은 진입점으로 남길지, 지울지 정합니다.
- 설치된 작업 공간은 업데이트하기 전까지 옛 update.sh 를 가지고 있습니다. 그래서 첫 전환 경로(옛 update.sh → 새 스킬)를 확인해야 합니다.
5. **개발 체크아웃**: git 으로 받는 저장소는 이 스킬을 쓰지 않습니다(`git pull`). 그쪽 마이그레이션은 #71 의 "읽을 때 한 번" 이 맡습니다.
## 완료 조건
1. 실행 중인 루프·열린 잡·실행 중인 세션이 있으면 아무것도 바꾸지 않고 거부한다.
2. 업데이트 뒤 `.mam.env`, 레지스트리(YAML·DB), 잡 기록, 위임 로그가 보존된다. `install_state` 는 지금처럼 합쳐진다.
3. 옛 형식 레지스트리(`kill_command`·`start_command` 가 있는 행)가 업데이트 뒤 새 형식이 되고, YAML 과 DB 가 같다.
4. 설치 중 실패하면 업데이트 전 상태로 돌아간다(지금의 trap 동작).
5. 옛 `.mam_deploy/update.sh` 로 시작한 업데이트도 새 스킬과 마이그레이션까지 도달한다.
6. 기존 테스트(`tests/test_issue8_update_state.py`, `test_deploy_*.py`)가 통과한다. macOS(bash 3.2, Python 3.9)에서도 통과한다.
## 관련
- #71: 레지스트리 형식 변경과 마이그레이션 함수
- #8: update 의 `install_state`·`version.txt` 처리
- #42: 정해진 절차는 문서보다 코드(진입점)로
레지스트리를 쓰는 함수(lib_py/atomic_yaml.py)를 거쳐 실행합니다. 평소 쓰기와 같은 잠금을 잡고, DB 와 YAML 을 함께 다시 씁니다.
출력은 한 줄입니다: registry-format: <이전> -> <이후>, rows changed: <n>
바꿨거나 이미 최신이면 exit 0, 오류면 0 이 아닌 값입니다. 여러 번 실행해도 결과가 같습니다.
형식 버전은 최상위 키 registry_format 입니다. 키가 없으면 1, 현재는 2 입니다.
계약은 lib_py/registry_format.py 의 모듈 docstring 한 곳에 적혀 있습니다. 업데이트 스킬은 이 진입점만 부르고, 형식의 세부 내용에 기대지 않습니다.
2. 본문 정정: "읽을 때 한 번" 이 아니라 "첫 쓰기 때 한 번"
본문 배경과 제안 5 는 "레지스트리를 읽을 때 옛 형식이면 한 번 실행" 이라고 썼습니다.
#71 은 읽기(load_state_json, status)를 잠금 없는 읽기 전용으로 두고, 쓰기 함수 안에서 잠금을 잡은 뒤 변환합니다. 따라서 첫 쓰기 전까지는 status 에 옛 형식 행이 보일 수 있습니다. 모니터가 곧 쓰기를 하므로 그 기간은 짧습니다.
제안 5(개발 체크아웃)와 완료 조건 5(옛 update.sh 로 시작한 첫 전환)도 이 "첫 쓰기 때 변환" 이 받쳐 줍니다. 업데이트 스킬이 진입점을 부르는 것은 그 시점을 업데이트 직후로 당기고, 결과를 출력하기 위해서입니다.
3. 전제: .mam 은 로컬 디스크에 둔다 (NFS 등은 지원하지 않음)
에이전트와 그 상태(레지스트리·잡 기록·로그)는 그 컴퓨터의 로컬 파일로만 관리합니다. NFS·SMB·SSHFS 같은 네트워크 파일 시스템 위의 .mam 은 공식 지원하지 않습니다.
다른 컴퓨터에 닿아야 하는 일은 별도 MCP·인터페이스나 원격 에이전트로 풉니다. 원격 지원에서 따로 다룹니다.
그래서 이 이슈에서는 네트워크 파일 시스템 대응을 넣지 않습니다. 네트워크 파일 시스템 위의 .mam 을 코드로 거부하는 일은 #74 에서 합니다. 거부는 레지스트리 쓰기 함수 안에서 하므로, 업데이트 스킬이 부르는 진입점도 따로 손대지 않아도 그 판정을 거치게 됩니다.
4. 순서
사전 확인(루프·열린 잡·실행 중인 세션이 있으면 거부, 모니터 정지)
백업 → 제거 → 설치 → 복원
새로 설치된.agents/skills 로 진입점 실행 (옛 코드가 돌지 않게, 설치가 끝난 뒤에만)
진입점 결과 출력, 모니터 재시작 여부 처리
백업은 모니터가 멈춘 뒤 떠야 합니다. 지금 update.sh 는 agent-sessions.* 를 파일 단위로 복사하므로, 복사 도중 쓰기가 일어나면 DB 와 WAL 이 서로 다른 시점의 것일 수 있습니다.
5. 백업 목록 확인 대상 (본문 문제 4)
지금 update.sh 가 복사하지 않는 .mam 항목입니다. 각각 보존 / 재생성 / 버림으로 나눠야 합니다.
항목
내용
herdr_workspace_id
이 작업 공간의 herdr 작업 공간 id (lib.sh 가 읽고 씀)
briefs/
루프·잡에 넘긴 계획서
resumelogs/, stoplogs/
resume·stop 기록
diagnostics/
진단 기록
multi-agent-mux-monitor.log
모니터 로그
shim/
lib.sh 가 만드는 herdr 래퍼 (재생성 가능해 보임)
*.lock (agent-sessions.db.lock, monitor.lock 등)
잠금 파일 (보존 불필요해 보임)
6. 정한 것: 진입점이 실패했을 때
진입점은 레지스트리를 새 형식으로 바꾸지 못하면 0 이 아닌 값으로 끝납니다. 예를 들면 YAML·DB 를 읽지 못할 때, 디스크가 가득 찼거나 권한이 없을 때, 다른 쓰기가 잠금을 오래 잡고 있을 때, 새 .venv 에 의존성이 빠졌을 때입니다.
설치는 유지하고, 실패를 분명히 출력하고 0 이 아닌 값으로 끝냅니다. 업데이트 전체를 되돌리지 않습니다.
레지스트리는 옛 형식으로 남지만, 형식 버전이 그대로이므로 다음 쓰기 때 다시 변환을 시도합니다.
레지스트리가 아예 없으면(YAML·DB 모두 없음) 실패로 보지 않습니다. "바꿀 것이 없음" 으로 출력하고 진입점을 건너뜁니다.
## #71 작업 결과를 반영한 보충
#71 구현(PR #73)에서 정해진 내용과, 그 과정에서 정한 전제를 정리합니다. 본문과 다른 곳은 이 댓글을 따릅니다.
### 1. 마이그레이션 진입점 (#71 이 제공)
```bash
PYTHONPATH=.agents/skills python3 -m lib_py.registry_format --yaml .mam/agent-sessions.yaml
```
- 레지스트리를 쓰는 함수(`lib_py/atomic_yaml.py`)를 거쳐 실행합니다. 평소 쓰기와 같은 잠금을 잡고, DB 와 YAML 을 함께 다시 씁니다.
- 출력은 한 줄입니다: `registry-format: <이전> -> <이후>, rows changed: <n>`
- 바꿨거나 이미 최신이면 exit 0, 오류면 0 이 아닌 값입니다. 여러 번 실행해도 결과가 같습니다.
- 형식 버전은 최상위 키 `registry_format` 입니다. 키가 없으면 1, 현재는 2 입니다.
- 계약은 `lib_py/registry_format.py` 의 모듈 docstring 한 곳에 적혀 있습니다. 업데이트 스킬은 이 진입점만 부르고, 형식의 세부 내용에 기대지 않습니다.
### 2. 본문 정정: "읽을 때 한 번" 이 아니라 "첫 쓰기 때 한 번"
- 본문 배경과 제안 5 는 "레지스트리를 읽을 때 옛 형식이면 한 번 실행" 이라고 썼습니다.
- #71 은 읽기(`load_state_json`, status)를 잠금 없는 읽기 전용으로 두고, **쓰기 함수 안에서 잠금을 잡은 뒤** 변환합니다. 따라서 첫 쓰기 전까지는 status 에 옛 형식 행이 보일 수 있습니다. 모니터가 곧 쓰기를 하므로 그 기간은 짧습니다.
- 제안 5(개발 체크아웃)와 완료 조건 5(옛 update.sh 로 시작한 첫 전환)도 이 "첫 쓰기 때 변환" 이 받쳐 줍니다. 업데이트 스킬이 진입점을 부르는 것은 그 시점을 업데이트 직후로 당기고, 결과를 출력하기 위해서입니다.
### 3. 전제: `.mam` 은 로컬 디스크에 둔다 (NFS 등은 지원하지 않음)
- 에이전트와 그 상태(레지스트리·잡 기록·로그)는 그 컴퓨터의 로컬 파일로만 관리합니다. NFS·SMB·SSHFS 같은 네트워크 파일 시스템 위의 `.mam` 은 공식 지원하지 않습니다.
- 다른 컴퓨터에 닿아야 하는 일은 별도 MCP·인터페이스나 원격 에이전트로 풉니다. 원격 지원에서 따로 다룹니다.
- 그래서 이 이슈에서는 네트워크 파일 시스템 대응을 넣지 않습니다. 네트워크 파일 시스템 위의 `.mam` 을 코드로 거부하는 일은 #74 에서 합니다. 거부는 레지스트리 쓰기 함수 안에서 하므로, 업데이트 스킬이 부르는 진입점도 따로 손대지 않아도 그 판정을 거치게 됩니다.
### 4. 순서
1. 사전 확인(루프·열린 잡·실행 중인 세션이 있으면 거부, 모니터 정지)
2. 백업 → 제거 → 설치 → 복원
3. **새로 설치된** `.agents/skills` 로 진입점 실행 (옛 코드가 돌지 않게, 설치가 끝난 뒤에만)
4. 진입점 결과 출력, 모니터 재시작 여부 처리
- 백업은 모니터가 멈춘 뒤 떠야 합니다. 지금 update.sh 는 `agent-sessions.*` 를 파일 단위로 복사하므로, 복사 도중 쓰기가 일어나면 DB 와 WAL 이 서로 다른 시점의 것일 수 있습니다.
### 5. 백업 목록 확인 대상 (본문 문제 4)
지금 update.sh 가 복사하지 않는 `.mam` 항목입니다. 각각 보존 / 재생성 / 버림으로 나눠야 합니다.
| 항목 | 내용 |
|---|---|
| `herdr_workspace_id` | 이 작업 공간의 herdr 작업 공간 id (`lib.sh` 가 읽고 씀) |
| `briefs/` | 루프·잡에 넘긴 계획서 |
| `resumelogs/`, `stoplogs/` | resume·stop 기록 |
| `diagnostics/` | 진단 기록 |
| `multi-agent-mux-monitor.log` | 모니터 로그 |
| `shim/` | lib.sh 가 만드는 herdr 래퍼 (재생성 가능해 보임) |
| `*.lock` (`agent-sessions.db.lock`, `monitor.lock` 등) | 잠금 파일 (보존 불필요해 보임) |
### 6. 정한 것: 진입점이 실패했을 때
진입점은 레지스트리를 새 형식으로 바꾸지 못하면 0 이 아닌 값으로 끝납니다. 예를 들면 YAML·DB 를 읽지 못할 때, 디스크가 가득 찼거나 권한이 없을 때, 다른 쓰기가 잠금을 오래 잡고 있을 때, 새 `.venv` 에 의존성이 빠졌을 때입니다.
- **설치는 유지하고, 실패를 분명히 출력하고 0 이 아닌 값으로 끝냅니다.** 업데이트 전체를 되돌리지 않습니다.
- 레지스트리는 옛 형식으로 남지만, 형식 버전이 그대로이므로 다음 쓰기 때 다시 변환을 시도합니다.
- **레지스트리가 아예 없으면**(YAML·DB 모두 없음) 실패로 보지 않습니다. "바꿀 것이 없음" 으로 출력하고 진입점을 건너뜁니다.
본문과 첫 댓글의 열린 항목을 하나씩 정했습니다. 본문·첫 댓글과 다른 곳은 이 댓글을 따릅니다. 특히 첫 댓글 4 의 "백업 → 제거 → 설치 → 복원" 에서 제거와 복원 단계는 없어집니다(결정 3). 결정 7–10 은 결정 1–6 을 검토하면서 새로 나온 항목입니다.
결정 1. 업데이트를 거부하는 조건: 하나라도 있으면 거부, 강제 옵션 없음
다음 중 하나라도 있으면 아무것도 바꾸지 않고 거부합니다.
실행 중인 루프: .mam/loop-guard-active 가 있고, 루프 잠금의 holder 판정(loop_lock.sh 의 pid·lstart 비교)이 live 또는 unknown 인 것. stale 이면 막지 않고 "낡은 표식" 이라고만 출력합니다.
열린 잡: 종결(completed·error·cancelled)되지 않은 잡 기록. dry_run: true 기록은 세지 않습니다. 읽을 수 없는 JSON 은 열린 잡으로 봅니다.
이 작업 공간의 .mam/jobs(와 MAM_JOBS_DIR)는 모든 기록을 봅니다.
레지스트리 행의 control_plane.mam_jobs_dir 처럼 다른 곳의 잡 디렉터리는, 이 작업 공간의 행(herdr:<name>)에 보낸 기록만 봅니다. 지금의 worktree.pyopen_jobs 는 .mam/jobs 쪽에서도 행에 보낸 기록만 보므로, 그대로 쓰면 이 결정보다 좁습니다.
이 작업 공간의 실행 중인 세션:
레지스트리의 running 행. 레지스트리는 지금 코드와 같이 DB 를 먼저, 없으면 YAML 을 읽습니다. 읽을 수 없으면 거부합니다. 그래서 오류를 삼키고 {} 를 돌려주는 파이썬 state.load_state_json 이 아니라, 읽지 못하면 1 로 끝나는 bash load_state_json strict 를 씁니다.
herdr 에 살아 있고 cwd 가 이 작업 공간 안인 에이전트(레지스트리에 행이 없는 세션 포함). 볼 서버는 이름을 미리 정하지 않고, herdr session list --json 이 running 으로 보고하는 서버 전부입니다. 지금 shim 도 서버가 떠 있는지 이 명령으로 봅니다. default 서버도 이 목록에 나옵니다(herdr 0.9.5 에서 확인). 그래서 임의의 --herdr-session 으로 띄운 뒤 행이 기록되기 전에 멈춘 세션도 찾습니다.
이 조회는 읽기만 합니다. 떠 있지 않은 서버를 띄우지 않습니다(MAM_NO_AUTOSTART=1 과 같은 규칙).
서버 목록을 얻지 못하거나, 떠 있는 서버에서 에이전트 목록을 얻지 못하면 거부합니다.
단, 업데이트를 부른 에이전트 자신은 뺍니다(결정 7).
대기 중인 세션만 남겨 두는 방식은 쓰지 않습니다. "대기 중" 판정을 믿어야 하고, 대기 중인 에이전트도 그 사이 사람이 말을 걸면 바뀌는 중인 스킬·훅 파일을 읽기 때문입니다.
강제 옵션은 두지 않습니다(#74 의 "우회 수단 없음" 과 같은 원칙). 그래서 낡은 기록이 영구 거부가 되지 않도록 위처럼 살아 있는 것만 셉니다.
거부할 때는 막고 있는 항목과 정리 방법을 함께 출력합니다.
세션: stop 스킬 명령. 업데이트가 끝난 뒤에는 resume 을 안내합니다. stop 이 대화 ID 를 행에 남기므로 resume 하면 대화가 이어집니다.
잡: 끝나기를 기다리거나 registry.py close --job <id> --request cancelled 로 닫습니다. 읽을 수 없는 잡 JSON 은 그 파일 경로를 출력하고, 확인한 뒤 손으로 옮기거나 지우도록 안내합니다.
루프: 끝나기를 기다립니다.
결정 2. 모니터 데몬: 업데이트 전 상태로 돌려놓는다
업데이트 전에 모니터 데몬이 떠 있으면 업데이트 스킬이 직접 끕니다(SIGTERM).
"끝났다" 는 래퍼 PID 가 없어지고 .mam/monitor.lock 을 잡을 수 있게 된 때입니다.
정해진 시간 안에 끝나지 않으면 설치를 시작하지 않습니다. SIGTERM 을 이미 보냈으므로 "데몬이 그대로 떠 있다" 고 보지 않습니다. 대신 다음을 합니다:
래퍼와 그 자식(파이썬 구독자)에 SIGKILL 을 보내고, 둘 다 없어지고 monitor.lock 을 잡을 수 있는지 다시 확인합니다.
확인되면, 아직 아무 파일도 바뀌지 않았으므로 옛 코드의 start_watchdog 로 모니터를 다시 띄우고 실패로 끝냅니다. 원래 떠 있던 상태로 돌아갑니다.
그래도 남아 있으면 남은 PID 와 잠금 상태를 출력하고, "모니터 상태를 되돌리지 못했다" 고 밝힌 채 실패로 끝냅니다.
백업은 모니터가 끝난 것을 확인한 뒤에 시작합니다.
업데이트가 끝나면 원래 떠 있었을 때만 다시 띄웁니다.
성공하면 새 코드로 띄웁니다(결정 5 의 2단계). 레지스트리 변환이 실패해도 모니터는 다시 띄우고, 그다음에 0 이 아닌 값으로 끝납니다.
1단계가 실패해 되돌리면, 되돌리기가 끝난 트리는 온전한 옛 버전이므로 그 트리의 start_watchdog 로 다시 띄웁니다(결정 5 의 예외). create 가 쓰는 같은 함수라서 띄우는 방식(절대 경로, 작업 공간 cwd, 부른 쪽 프로세스 그룹에서 분리, .mam/multi-agent-mux-monitor.log 에 기록, --idle-timeout 0)이 같습니다. 그래서 다음 업데이트의 정지 단계와 create·resume 이 이 데몬을 찾습니다. reconcile.sh 를 직접 실행하면 데몬을 찾는 pgrep 패턴(절대 경로)과 맞지 않아 다음 업데이트가 모니터를 멈추지 못합니다.
MAM_NO_SUBSCRIBER_DAEMON=1 이면 원래 떠 있었더라도 다시 띄우지 않고, 그렇게 했다고 출력합니다. 플래그가 우선합니다. 지금의 daemon.py 도 이 값이면 새로 띄우지 않습니다.
resume 도 모니터를 띄웁니다(이 이슈에 포함). 지금은 create 만, 그것도 잡을 함께 등록하는 경로에서만 모니터를 띄웁니다. 결정 1 때문에 "stop → 업데이트 → resume" 흐름이 매번 생기므로, resume 도 create 처럼 데몬을 보장(start_watchdog)해서 업데이트 뒤에 모니터가 없는 구간을 메웁니다. 이미 살아 있는 세션을 다시 등록하는 경로와 새로 띄우는 경로 모두 해당합니다.
결정 3. .mam 은 지우지 않는다: 제자리 설치
지금 update.sh 는 .mam 을 통째로 지운 뒤(remove.sh) 정해진 목록만 되돌립니다. 그래서 목록에 없는 항목이 업데이트 때 사라집니다. 예: herdr_workspace_id, base/(아래), diagnostics/, 모니터 로그, 그리고 디스크에 있다면 첫 댓글 5 의 briefs/·resumelogs/·stoplogs/.
업데이트는 remove.sh 를 부르지 않고, 새 install.sh 를 제자리에서 실행합니다. .mam 은 남겨 두고, 설치기가 관리하는 항목만 새로 씁니다: install_manifest.txt, install_state, asset_hashes.txt, version.txt, base/, skill-backups/.
제자리 설치에서 install.sh 는 .agents/ 아래 프레임워크 소유 파일마다 판정합니다(asset_hashes.txt 기준).
없으면 새로 복사하고, 사용자가 고치지 않은 파일은 갱신합니다.
사용자가 고친 파일은 보존합니다(PRESERVE_CUSTOM). merge 하지 않습니다.
.agents/hooks.json 하나만 .mam/base/ 의 upstream 사본을 기준으로 키 단위 3-way merge 합니다. base/ 에는 이 파일의 사본만 들어갑니다.
install_state 는 키 단위로 합칩니다(#8). 같은 키는 한 줄만 남습니다. 지금 install.sh 는 파일이 있으면 gitignore_created=1 을 덧붙이기만 하므로, 제자리 설치에서는 두 줄이 되고 remove.sh 가 그 값을 1 로 읽지 못합니다. 새 install.sh 에서 같은 키는 덮어쓰게 고칩니다.
.mam 안에서 옛 버전이 남긴 쓰지 않는 파일은 그대로 둡니다. 지워서 생기는 손실보다 남아서 드는 비용이 작다고 봅니다. .agents/ 아래에서 없어진 파일은 다르게 다룹니다(결정 9).
백업은 실패했을 때 되돌리기 위한 용도로만 둡니다. 범위는 1단계가 바꿀 수 있는 것 전부입니다.
.agents/
.mam_deploy/
설치기가 없을 때만 만드는 루트 파일(AGENTS.md 등의 루트 문서, .mam.env.example, .mam.env)과 .gitignore. 업데이트 전에 없던 파일은 되돌릴 때 지웁니다.
.mam 의 설치기 관리 항목(위 목록)
레지스트리 YAML·DB. 1단계는 레지스트리를 바꾸지 않지만 안전을 위해 둡니다.
.venv(결정 10)
레지스트리 백업은 모니터를 멈추고 결정 1 의 확인을 통과한 뒤, 평소 쓰기와 같은 잠금(agent-sessions.db.lock)을 잡고 뜹니다. DB 는 SQLite backup API 로 복사합니다. 파일 단위 복사로 -wal·-shm 과 시점이 어긋나는 일을 막기 위해서입니다. 되돌릴 때는 DB 를 백업으로 바꾸고 남은 -wal·-shm 을 지웁니다. macOS 는 닫은 뒤에도 이 파일들을 남깁니다.
되돌리기가 끝나면 .agents/ 와 asset_hashes.txt·version.txt 가 같은 옛 버전을 가리킵니다. 그래서 다음 업데이트에서 반쯤 깔린 새 파일이 사용자 수정으로 잘못 판정되지 않습니다.
.mam 이 사라지는 구간이 없으므로, 모니터가 잠금 파일이 사라진 것을 보고 저절로 꺼지는 일도 없습니다. 그래서 결정 2 의 명시적 정지가 필요합니다.
결정 4. .mam_deploy/update.sh 는 새 버전에서 뺀다
업데이트하는 길은 update 스킬 하나로 둡니다("상태를 바꾸는 인터페이스는 스킬을 거친다").
고칠 곳:
deploy/update.sh 를 지웁니다.
install.sh:
옛 위치 이전 코드(for legacy_script in remove.sh update.sh)에서 update.sh 를 뺍니다.
deploy/update.sh 를 .mam_deploy/update.sh 로 복사하고 설치 목록에 넣는 블록을 지웁니다.
제자리 설치에서 .mam_deploy/update.sh 가 있으면 지우고 설치 목록에서도 뺍니다. 지금 install.sh 는 stage 에 있을 때만 복사하고, 없다고 지우지는 않습니다.
deploy/install_mam.sh: update.sh 복사와 설치 목록 기록을 같은 식으로 지웁니다.
remove.sh: .mam_deploy/update.sh 를 지우는 줄은 그대로 둡니다. 옛 작업 공간을 정리하는 데 필요합니다.
deploy/gitea-ci.yml: shellcheck deploy/update.sh 줄을 뺍니다. 그대로 두면 CI 가 실패합니다. update 스킬의 스크립트를 shellcheck 대상에 넣습니다.
문서(deploy/README.md, deploy/INSTALL.md, 루트 README.md·README.ko.md 의 설치 절과 저장소 구조 그림 등)는 update 스킬을 가리키게 고칩니다. VERSIONS.md 의 지난 릴리스 기록은 그대로 둡니다.
첫 전환은 옛 update.sh 로 한 번 진행합니다. 설치된 작업 공간에 이미 있는 옛 파일은 고칠 수 없기 때문입니다.
옛 update.sh 는 사전 확인을 하지 않고, 모니터를 멈추지 않고, .mam 을 지운 뒤 목록만 되돌립니다. 이 일이 마지막으로 한 번 일어납니다.
이때 잃는 것: herdr_workspace_id, diagnostics/, 모니터 로그, shim/(lib.sh 가 다시 만듦), 디스크에 있다면 첫 댓글 5 의 briefs/·resumelogs/·stoplogs/, 그리고 .mam 밖의 .cache/multi-agent-mux-monitor. 릴리스 노트에 이 목록을 적습니다.
릴리스 노트에 다음 순서를 적습니다:
루프가 끝나기를 기다리고, 열린 잡을 끝내거나 닫는다.
세션을 모두 stop 한다.
모니터(reconcile.sh --subscribe)를 멈춘다.
옛 update.sh 를 실행한다.
바로 update 스킬을 한 번 더 실행한다.
옛 update.sh 는 Gitea main 의 새 install.sh 로 설치하므로 update 스킬이 들어옵니다. 옛 remove.sh 는 .mam_deploy/update.sh 를 (설치 목록과 상관없이) 지우고, 새 install.sh 는 그것을 다시 만들지 않으므로 옛 파일은 남지 않습니다.
레지스트리 변환은 옛 update.sh 안에서는 일어나지 않습니다. 옛 update.sh 는 레지스트리를 파일로 복사만 하고, 그 복사는 변환하지 않습니다.
그래서 5 번에서 update 스킬을 한 번 더 실행해 진입점을 부릅니다. 레지스트리 변환이 이때 일어납니다.
5 번은 보통의 업데이트입니다. 설치할 버전을 고정하지 않으므로, 4 번과 5 번 사이에 main 이 바뀌면 다른 코드가 설치될 수 있습니다. 그래서 결정 1 의 거부, 결정 3 의 백업과 되돌리기가 모두 그대로 적용됩니다. main 이 그대로면 프레임워크 파일은 바뀌지 않습니다.
5 번을 하지 않아도 #71 의 "첫 쓰기 때 변환" 으로 결국 새 형식이 됩니다. 다만 그 시점은 업데이트 뒤 첫 레지스트리 쓰기(resume·create 등)입니다.
첫 전환 뒤에는 모니터가 없습니다. 3 번에서 멈췄고, 5 번의 스킬은 원래 떠 있지 않았으므로 띄우지 않습니다. resume 하면 결정 2 에 따라 뜹니다.
두 번째 업데이트부터는 새 스킬로만 진행합니다.
결정 5. 스킬이 자기 자신을 바꾸는 문제: 두 단계로 나눈다
옛 코드가 위험해지는 것은 설치가 파일을 바꾸기 시작한 뒤 부터입니다. 그래서 그 앞까지는 옛 코드의 규칙을 그대로 씁니다.
1단계 (옛 코드):
사전 확인(결정 1), 모니터 정지(결정 2), 되돌리기용 백업(결정 3)은 아직 바뀌지 않은 옛 코드(lib.sh·lib_py)의 함수와 진입점으로 합니다. 판정 규칙을 다시 구현하지 않기 위해서입니다(#71 의 "규칙 하나", #42 의 "절차는 코드로").
설치를 시작하기 전에 스킬은 업데이트 스크립트를 작업 트리 밖 임시 폴더로 복사하고, 그 복사본으로 실행을 넘깁니다. 복사본은 그 뒤로 lib.sh·lib_py 를 부르지 않습니다. 설치를 하고, 실패하면 되돌리기도 이 복사본이 합니다. 예외는 되돌리기가 끝난 뒤의 모니터 재기동입니다. 그때 트리는 다시 온전한 옛 버전이므로 그 트리의 start_watchdog 를 부릅니다(결정 2).
복사본은 macOS 의 bash 3.2 로 돌아야 합니다.
2단계 (새 코드, 새 프로세스):
설치가 성공하면 새로 설치된 코드의 진입점을 별도 프로세스로 실행합니다. 옛 코드는 새 코드를 import 하지 않습니다.
레지스트리 변환: YAML·DB 가 둘 다 없으면 진입점을 부르지 않고 "바꿀 것이 없음" 이라고 출력합니다. 진입점은 레지스트리가 없으면 스스로 1 로 끝나기 때문입니다. 있으면 첫 댓글 1 의 명령(PYTHONPATH=.agents/skills, --yaml)으로 부릅니다.
그다음 결정 2 대로 모니터를 복원하고, 변환 결과에 따라 종료 코드를 정합니다.
Gitea 에서 새 버전의 업데이트 스크립트를 받아 실행하는 방식은 쓰지 않습니다. 새 스크립트가 모든 옛 설치를 상대해야 해 시험할 범위가 커지기 때문입니다.
결정 6. 지금 동작을 유지하는 것
항목
동작
설치할 버전
Gitea main 의 install.sh (MAM_INSTALLER_URL 로 바꿀 수 있음)
확인 질문
[y/N] 으로 묻고, 터미널이 아니면 -y/--yes 가 필요하다. 지금의 별칭 --force 는 받지 않는다(결정 1 의 "강제 옵션 없음" 과 헷갈리지 않게). 에이전트는 --yes 를 붙여 부른다(결정 7)
설치되지 않은 곳
설치 흔적(.mam_deploy/remove.sh 또는 루트의 remove.sh)이 없으면 거부한다. 개발용 git 체크아웃도 여기서 거부되고, 그쪽은 git pull 과 #71 의 "첫 쓰기 때 변환" 이 맡는다
레지스트리 변환 실패
첫 댓글 6 그대로: 설치는 유지하고 실패를 출력하며 0 이 아닌 값으로 끝난다. 모니터는 결정 2 대로 복원한다. 레지스트리가 없으면 건너뛴다(결정 5)
결정 7. 업데이트를 부른 에이전트 자신은 거부 대상에서 뺀다
herdr pane 안의 에이전트(이 작업 공간의 오케스트레이터 포함)가 스킬을 부르면, 그 에이전트 자신도 결정 1 의 "herdr 에 살아 있는 에이전트" 에 걸려 항상 거부됩니다. 그래서 부른 에이전트 하나만 뺍니다.
같은 에이전트로 묶어 빼는 것: 그 pane, 같은 세션의 레지스트리 running 행, 그 세션에 보낸 지금 실행 중인 잡 하나. 그 밖의 세션·잡·루프가 하나라도 있으면 그대로 거부합니다.
부른 에이전트는 서버와 pane 을 함께 써서 가려냅니다. 결정 1 은 모든 서버를 보는데, pane ID 와 에이전트 이름은 서버마다 따로이기 때문입니다.
서버는 lib.sh 의 _herdr_caller_server(HERDR_SESSION, 없으면 HERDR_SOCKET_PATH 의 모양)로, pane 은 HERDR_PANE_ID 로 정합니다.
그 서버의 에이전트 목록에서 그 pane 의 에이전트 이름을 찾고, 그 서버·이름의 행과 그 세션(herdr:<name>)에 보낸 잡을 묶습니다.
서버나 pane 을 정하지 못하면 아무것도 빼지 않습니다. 그러면 부른 에이전트 자신 때문에 거부되고, 그 이유를 출력합니다. 다른 서버의 같은 pane ID 는 빼지 않습니다.
그 에이전트의 훅(.agents/hooks.json)이 업데이트 중에 바뀌는 위험은 받아들입니다. 업데이트 스크립트 자체는 1단계에서 복사본으로 돌고, herdr 밖에서 도는 에이전트는 어느 방식으로도 가려낼 수 없어 같은 위험을 지기 때문입니다.
에이전트는 --yes 를 붙여 부릅니다.
결정 8. 업데이트 중에는 새 세션·루프·잡을 막는다
사전 확인과 설치 사이에 다른 터미널의 create·resume·run_loop.sh·delegate 가 새 세션이나 잡을 시작하면 결정 1 의 목적이 깨집니다.
업데이트는 사전 확인 전에.mam/update-in-progress 표식을 만들고, 2단계까지 끝난 뒤(성공·실패 모두) 지웁니다.
표식에는 업데이트의 pid·lstart 를 적습니다. 살아 있는지는 루프 잠금과 같은 방식(pid·lstart 비교)으로 판정합니다. 비정상 종료로 남은 표식(stale)은 막지 않고 "낡은 표식" 이라고만 출력합니다.
create·resume·run_loop.sh·delegate submit 은 표식이 live 또는 unknown 이면 거부하고, 업데이트 중이라고 출력합니다.
표식을 한 번 읽는 것만으로는 막지 못합니다. create 가 표식이 없는 것을 본 뒤 spawn 전에 멈춰 있는 동안 업데이트가 표식을 만들고 사전 확인을 통과할 수 있습니다. 그래서 진입과 표식을 입장 잠금 하나로 맞춥니다.
입장 잠금은 .mam/update-admission.lock 의 BSD flock 입니다. pane_lock 과 같은 방식으로, 열어 둔 fd 에 lib_py 도우미가 flock 을 걸고, 대기 시간에 상한을 둡니다. 잠금은 그 fd 를 쥔 프로세스가 모두 끝나면 어떻게 끝나든 풀리므로 낡은 잠금이 남지 않습니다. macOS 에 flock(1) 이 없으므로 fcntl.flock 을 씁니다.
진입 쪽(create·resume·run_loop.sh·delegate submit): 공유 잠금(LOCK_SH)을 잡고 표식을 확인합니다. 그 시작이 결정 1 의 사전 확인에 보이게 될 때까지 잠금을 쥡니다. create·resume 은 행을 기록할 때까지, run_loop.sh 는 루프 표식을 게시할 때까지, delegate submit 은 잡 기록을 남길 때까지입니다. 그다음 fd 를 닫습니다. delegate submit 은 대상 세션이 속한 작업 공간의 잠금을 씁니다. 오래 사는 자식(에이전트, 모니터, 루프 본체)에게 fd 를 물려주지 않습니다(pane_lock 과 같은 규칙).
업데이트 쪽: 배타 잠금(LOCK_EX)을 잡은 채로 표식을 게시하고, 잠금을 놓은 뒤 사전 확인을 합니다. 배타 잠금은 이미 확인을 통과한 진입이 기록을 남길 때까지 기다리므로, 그 진입은 사전 확인에 반드시 보입니다. 표식을 게시한 뒤의 진입은 표식을 보고 거부됩니다. 상한 안에 잠금을 잡지 못하면 아무것도 바꾸지 않고 거부합니다.
표식 게시는 루프 잠금(loop_lock.sh)과 같이 원자적으로 합니다. 임시 파일을 hard link 로 게시하고, 이미 있으면 그 holder 를 판정합니다.
live·unknown 이면 다른 업데이트가 진행 중이므로 아무것도 바꾸지 않고 거부합니다. 두 업데이트가 같은 트리를 동시에 바꾸지 않습니다.
stale 일 때만 루프 잠금의 가져오기 절차로 넘겨받습니다.
다른 실행의 표식을 덮어쓰거나 지우지 않습니다. 해제는 표식의 pid·lstart 가 자기 것과 같을 때만 합니다.
복사본으로 넘길 때는 exec 로 넘깁니다. pid 와 lstart 가 그대로이므로 표식이 그 사이에 stale 로 보이는 구간이 없습니다. 2단계는 복사본의 자식 프로세스로 돌고, 복사본은 그것이 끝날 때까지 기다린 뒤 표식을 지웁니다.
결정 9. 새 버전에서 없어진 .agents/ 아래 파일은 고치지 않은 것만 지운다
지금 install.sh 는 stage 에 있는 파일만 보고, 없어진 파일을 지우지 않습니다. 남은 옛 스킬 폴더는 에이전트가 스킬 목록으로 읽고, 남은 옛 훅이나 스크립트가 실행될 수도 있습니다. 결정 1 이 업데이트를 거부하는 이유와 같은 문제입니다.
옛 설치 목록과 asset_hashes.txt 에 있고 새 버전에는 없는 프레임워크 소유 파일 가운데, 해시가 옛 설치와 같은 것(사용자가 고치지 않은 것)만 지웁니다.
고친 파일은 PRESERVE_CUSTOM 처럼 남기고 목록을 출력합니다.
지운 파일은 .mam/base/·설치 목록·asset_hashes.txt 에서도 정리합니다.
지운 파일도 결정 3 의 백업 범위(.agents/)에 들어 있으므로, 실패하면 되돌아갑니다.
결정 10. .venv 도 백업하고, 실패하면 되돌린다
제자리 설치는 있는 .venv 를 재사용합니다. pip 자체만 --upgrade 로 올리고, 프로젝트 의존성은 --upgrade 없이 pip install -r requirements.txt 로 깝니다. 그래서 이미 명세를 만족하는 패키지는 그대로이고, 명세가 높아진 패키지만 올라가고, 새로 적힌 패키지는 깔리고, 명세에서 빠진 패키지는 지워지지 않습니다. 설치 중에 실패하면 .venv 가 일부만 바뀐 채 남을 수 있습니다.
그래서 .venv 를 결정 3 의 백업 범위에 넣고, 실패하면 그대로 되돌립니다. 같은 경로로 되돌리므로 .venv 안에 적힌 경로도 그대로 맞습니다.
디스크와 시간이 들지만, 조건 4 의 "업데이트 전 상태" 를 예외 없이 지키기 위해서입니다.
완료 조건 (본문을 고친 것)
조건 1 → "실행 중인 루프·열린 잡·실행 중인 세션(레지스트리 행이 없는 herdr 세션 포함)이 있으면 아무것도 바꾸지 않고 거부한다. 강제 옵션은 없다. 낡은 루프 표식과 dry_run 잡 기록은 거부 사유가 아니다."
조건 2 → "업데이트 뒤 .mam 의 설치기 관리 항목이 아닌 것은 모두 그대로 남는다(.mam.env, 레지스트리, 잡 기록, 위임 로그, herdr_workspace_id 등). install_state 는 키 단위로 합쳐지고, 같은 키는 한 줄이다." 이 조건은 update 스킬의 제자리 설치에 적용합니다. 첫 전환의 옛 update.sh 한 번은 결정 4 에 적은 목록 밖 항목을 잃고, 그 손실은 이 조건의 예외입니다.
조건 3 → "옛 형식 레지스트리가 update 스킬 실행 뒤 새 형식이 되고, YAML 과 DB 가 같다." 첫 전환(조건 5)에서는 릴리스 노트의 5 번 뒤에 성립한다.
조건 4 → "설치 중 실패하면 결정 3 의 백업 범위(.venv 포함)가 업데이트 전 상태로 돌아간다. 되돌린 뒤 .agents/·asset_hashes.txt·version.txt 가 같은 옛 버전을 가리키고, 업데이트 전에 없던 루트 파일은 남지 않으며, update 스킬을 다시 실행할 수 있다." 지금의 trap 은 이렇게 하지 않습니다. remove.sh 가 지운 .agents/ 등을 되돌리지 못해, 코드도 remove.sh 도 없는 작업 공간을 남깁니다.
조건 5 → "옛 .mam_deploy/update.sh 로 업데이트하면 설치 결과에 update 스킬이 들어가고 옛 update.sh 는 남지 않는다. 그 뒤 update 스킬을 한 번 실행하면 레지스트리가 새 형식이 된다. 실행하지 않아도 첫 쓰기 때 새 형식이 된다."
조건 6 → "기존 테스트가 통과한다. macOS(bash 3.2, Python 3.9)에서도 통과한다." .mam_deploy/update.sh 를 실행하거나 그것이 깔리는지 보는 테스트는 tests/test_issue8_update_state.py 와 tests/test_deploy_layout.py(update.sh 를 다루는 두 시험) 둘입니다. 이 둘은 update 스킬을 부르고 .mam_deploy/update.sh 가 생기지 않는 것을 확인하도록 고칩니다. 지키려는 내용(#8 의 install_state·version.txt, 여러 번 업데이트해도 상태 보존)은 그대로 둡니다. tests/test_deploy_freshness.py(주석에만 update.sh 가 나옴)와 tests/test_deploy_registry_merge.py(hooks.json 병합)는 고치지 않습니다.
추가: "모니터가 떠 있던 작업 공간은 업데이트 뒤(성공·1단계 실패·변환 실패 모두) 모니터가 떠 있고, 없던 곳은 없다. MAM_NO_SUBSCRIBER_DAEMON=1 이면 다시 띄우지 않는다."
추가: "모니터가 시간 안에 끝나지 않으면 설치를 시작하지 않고, 결정 2 의 SIGKILL·재기동 경로를 따른다. 되돌리지 못하면 그렇게 출력한다."
추가: "레지스트리에 행이 없고, 환경에도 행에도 없는 서버에서 도는 에이전트가 이 작업 공간 안에 있으면 거부한다."
추가: "resume 이 모니터 데몬을 보장한다."
추가: "herdr pane 안의 에이전트가 부르면, 그 에이전트(pane·행·실행 중인 잡 하나)는 거부 사유가 아니다. 다른 세션·잡·루프가 있으면 거부한다."
추가: "업데이트 중에는 create·resume·run_loop.sh·delegate submit 이 거부된다. 업데이트가 비정상 종료해 남은 표식은 막지 않는다."
추가: 결정 8 의 경합을 시험한다.
표식이 없음을 본 뒤 spawn·행 기록 직전에 멈춘 create 와 업데이트가 겹치면, 업데이트는 그 create 가 행을 남길 때까지 기다린 뒤 그 세션 때문에 거부되거나, 상한을 넘어 거부된다. 어느 쪽이든 설치는 시작되지 않는다.
두 업데이트를 동시에 시작하면 하나만 진행하고, 다른 하나는 아무것도 바꾸지 않고 거부된다. 진행한 쪽의 표식은 덮이거나 지워지지 않는다.
복사본으로 넘기는 동안 들어온 delegate submit 은 거부된다(표식이 계속 live).
추가: "부른 에이전트는 서버와 pane 으로 가려낸다. 다른 서버에 같은 pane ID 가 있어도 부른 에이전트 하나만 빠지고, 서버나 pane 을 정하지 못하면 아무것도 빠지지 않는다."
추가: "새 버전에서 없어진 프레임워크 파일 가운데 고치지 않은 것은 지워지고, 고친 것은 남으며 그 목록이 출력된다."
추가: "레지스트리 백업은 -wal·-shm 에 남은 커밋까지 담고, 되돌린 뒤 DB 와 YAML 이 업데이트 전과 같다."
## 구현 전에 정한 것 (결정 1–10)
본문과 첫 댓글의 열린 항목을 하나씩 정했습니다. 본문·첫 댓글과 다른 곳은 이 댓글을 따릅니다. 특히 첫 댓글 4 의 "백업 → 제거 → 설치 → 복원" 에서 **제거와 복원 단계는 없어집니다**(결정 3). 결정 7–10 은 결정 1–6 을 검토하면서 새로 나온 항목입니다.
### 결정 1. 업데이트를 거부하는 조건: 하나라도 있으면 거부, 강제 옵션 없음
- 다음 중 하나라도 있으면 **아무것도 바꾸지 않고** 거부합니다.
- 실행 중인 루프: `.mam/loop-guard-active` 가 있고, 루프 잠금의 holder 판정(`loop_lock.sh` 의 pid·lstart 비교)이 `live` 또는 `unknown` 인 것. `stale` 이면 막지 않고 "낡은 표식" 이라고만 출력합니다.
- 열린 잡: 종결(`completed`·`error`·`cancelled`)되지 않은 잡 기록. `dry_run: true` 기록은 세지 않습니다. 읽을 수 없는 JSON 은 열린 잡으로 봅니다.
- 이 작업 공간의 `.mam/jobs`(와 `MAM_JOBS_DIR`)는 모든 기록을 봅니다.
- 레지스트리 행의 `control_plane.mam_jobs_dir` 처럼 다른 곳의 잡 디렉터리는, 이 작업 공간의 행(`herdr:<name>`)에 보낸 기록만 봅니다. 지금의 `worktree.py` `open_jobs` 는 `.mam/jobs` 쪽에서도 행에 보낸 기록만 보므로, 그대로 쓰면 이 결정보다 좁습니다.
- 이 작업 공간의 실행 중인 세션:
- 레지스트리의 `running` 행. 레지스트리는 지금 코드와 같이 DB 를 먼저, 없으면 YAML 을 읽습니다. 읽을 수 없으면 거부합니다. 그래서 오류를 삼키고 `{}` 를 돌려주는 파이썬 `state.load_state_json` 이 아니라, 읽지 못하면 1 로 끝나는 bash `load_state_json strict` 를 씁니다.
- herdr 에 살아 있고 cwd 가 이 작업 공간 안인 에이전트(레지스트리에 행이 없는 세션 포함). 볼 서버는 이름을 미리 정하지 않고, `herdr session list --json` 이 `running` 으로 보고하는 서버 전부입니다. 지금 shim 도 서버가 떠 있는지 이 명령으로 봅니다. `default` 서버도 이 목록에 나옵니다(herdr 0.9.5 에서 확인). 그래서 임의의 `--herdr-session` 으로 띄운 뒤 행이 기록되기 전에 멈춘 세션도 찾습니다.
- 이 조회는 읽기만 합니다. 떠 있지 않은 서버를 띄우지 않습니다(`MAM_NO_AUTOSTART=1` 과 같은 규칙).
- 서버 목록을 얻지 못하거나, 떠 있는 서버에서 에이전트 목록을 얻지 못하면 거부합니다.
- 단, 업데이트를 부른 에이전트 자신은 뺍니다(결정 7).
- 대기 중인 세션만 남겨 두는 방식은 쓰지 않습니다. "대기 중" 판정을 믿어야 하고, 대기 중인 에이전트도 그 사이 사람이 말을 걸면 바뀌는 중인 스킬·훅 파일을 읽기 때문입니다.
- **강제 옵션은 두지 않습니다**(#74 의 "우회 수단 없음" 과 같은 원칙). 그래서 낡은 기록이 영구 거부가 되지 않도록 위처럼 살아 있는 것만 셉니다.
- 거부할 때는 막고 있는 항목과 정리 방법을 함께 출력합니다.
- 세션: stop 스킬 명령. 업데이트가 끝난 뒤에는 resume 을 안내합니다. stop 이 대화 ID 를 행에 남기므로 resume 하면 대화가 이어집니다.
- 잡: 끝나기를 기다리거나 `registry.py close --job <id> --request cancelled` 로 닫습니다. 읽을 수 없는 잡 JSON 은 그 파일 경로를 출력하고, 확인한 뒤 손으로 옮기거나 지우도록 안내합니다.
- 루프: 끝나기를 기다립니다.
### 결정 2. 모니터 데몬: 업데이트 전 상태로 돌려놓는다
- 업데이트 전에 모니터 데몬이 떠 있으면 **업데이트 스킬이 직접 끕니다**(SIGTERM).
- "끝났다" 는 래퍼 PID 가 없어지고 `.mam/monitor.lock` 을 잡을 수 있게 된 때입니다.
- 정해진 시간 안에 끝나지 않으면 설치를 시작하지 않습니다. SIGTERM 을 이미 보냈으므로 "데몬이 그대로 떠 있다" 고 보지 않습니다. 대신 다음을 합니다:
1. 래퍼와 그 자식(파이썬 구독자)에 SIGKILL 을 보내고, 둘 다 없어지고 `monitor.lock` 을 잡을 수 있는지 다시 확인합니다.
2. 확인되면, 아직 아무 파일도 바뀌지 않았으므로 옛 코드의 `start_watchdog` 로 모니터를 다시 띄우고 실패로 끝냅니다. 원래 떠 있던 상태로 돌아갑니다.
3. 그래도 남아 있으면 남은 PID 와 잠금 상태를 출력하고, "모니터 상태를 되돌리지 못했다" 고 밝힌 채 실패로 끝냅니다.
- 백업은 모니터가 끝난 것을 확인한 뒤에 시작합니다.
- 업데이트가 끝나면 **원래 떠 있었을 때만** 다시 띄웁니다.
- 성공하면 새 코드로 띄웁니다(결정 5 의 2단계). 레지스트리 변환이 실패해도 모니터는 다시 띄우고, 그다음에 0 이 아닌 값으로 끝납니다.
- 1단계가 실패해 되돌리면, 되돌리기가 끝난 트리는 온전한 옛 버전이므로 그 트리의 `start_watchdog` 로 다시 띄웁니다(결정 5 의 예외). create 가 쓰는 같은 함수라서 띄우는 방식(절대 경로, 작업 공간 cwd, 부른 쪽 프로세스 그룹에서 분리, `.mam/multi-agent-mux-monitor.log` 에 기록, `--idle-timeout 0`)이 같습니다. 그래서 다음 업데이트의 정지 단계와 create·resume 이 이 데몬을 찾습니다. `reconcile.sh` 를 직접 실행하면 데몬을 찾는 `pgrep` 패턴(절대 경로)과 맞지 않아 다음 업데이트가 모니터를 멈추지 못합니다.
- `MAM_NO_SUBSCRIBER_DAEMON=1` 이면 원래 떠 있었더라도 다시 띄우지 않고, 그렇게 했다고 출력합니다. 플래그가 우선합니다. 지금의 `daemon.py` 도 이 값이면 새로 띄우지 않습니다.
- **resume 도 모니터를 띄웁니다(이 이슈에 포함).** 지금은 create 만, 그것도 잡을 함께 등록하는 경로에서만 모니터를 띄웁니다. 결정 1 때문에 "stop → 업데이트 → resume" 흐름이 매번 생기므로, resume 도 create 처럼 데몬을 보장(`start_watchdog`)해서 업데이트 뒤에 모니터가 없는 구간을 메웁니다. 이미 살아 있는 세션을 다시 등록하는 경로와 새로 띄우는 경로 모두 해당합니다.
### 결정 3. `.mam` 은 지우지 않는다: 제자리 설치
- 지금 update.sh 는 `.mam` 을 통째로 지운 뒤(`remove.sh`) 정해진 목록만 되돌립니다. 그래서 목록에 없는 항목이 업데이트 때 사라집니다. 예: `herdr_workspace_id`, `base/`(아래), `diagnostics/`, 모니터 로그, 그리고 디스크에 있다면 첫 댓글 5 의 `briefs/`·`resumelogs/`·`stoplogs/`.
- 업데이트는 `remove.sh` 를 부르지 않고, 새 `install.sh` 를 **제자리에서** 실행합니다. `.mam` 은 남겨 두고, **설치기가 관리하는 항목만** 새로 씁니다: `install_manifest.txt`, `install_state`, `asset_hashes.txt`, `version.txt`, `base/`, `skill-backups/`.
- 제자리 설치에서 install.sh 는 `.agents/` 아래 프레임워크 소유 파일마다 판정합니다(`asset_hashes.txt` 기준).
- 없으면 새로 복사하고, 사용자가 고치지 않은 파일은 갱신합니다.
- 사용자가 고친 파일은 보존합니다(`PRESERVE_CUSTOM`). merge 하지 않습니다.
- `.agents/hooks.json` 하나만 `.mam/base/` 의 upstream 사본을 기준으로 키 단위 3-way merge 합니다. `base/` 에는 이 파일의 사본만 들어갑니다.
- 세션 레지스트리(`agent-sessions.yaml`·`.db`)는 설치기가 건드리지 않습니다.
- `install_state` 는 키 단위로 합칩니다(#8). 같은 키는 한 줄만 남습니다. 지금 install.sh 는 파일이 있으면 `gitignore_created=1` 을 덧붙이기만 하므로, 제자리 설치에서는 두 줄이 되고 `remove.sh` 가 그 값을 `1` 로 읽지 못합니다. 새 install.sh 에서 같은 키는 덮어쓰게 고칩니다.
- `.mam` 안에서 옛 버전이 남긴 쓰지 않는 파일은 그대로 둡니다. 지워서 생기는 손실보다 남아서 드는 비용이 작다고 봅니다. `.agents/` 아래에서 없어진 파일은 다르게 다룹니다(결정 9).
- 백업은 **실패했을 때 되돌리기 위한 용도로만** 둡니다. 범위는 1단계가 바꿀 수 있는 것 전부입니다.
- `.agents/`
- `.mam_deploy/`
- 설치기가 없을 때만 만드는 루트 파일(`AGENTS.md` 등의 루트 문서, `.mam.env.example`, `.mam.env`)과 `.gitignore`. 업데이트 전에 없던 파일은 되돌릴 때 지웁니다.
- `.mam` 의 설치기 관리 항목(위 목록)
- 레지스트리 YAML·DB. 1단계는 레지스트리를 바꾸지 않지만 안전을 위해 둡니다.
- `.venv`(결정 10)
- 레지스트리 백업은 모니터를 멈추고 결정 1 의 확인을 통과한 뒤, 평소 쓰기와 같은 잠금(`agent-sessions.db.lock`)을 잡고 뜹니다. DB 는 SQLite backup API 로 복사합니다. 파일 단위 복사로 `-wal`·`-shm` 과 시점이 어긋나는 일을 막기 위해서입니다. 되돌릴 때는 DB 를 백업으로 바꾸고 남은 `-wal`·`-shm` 을 지웁니다. macOS 는 닫은 뒤에도 이 파일들을 남깁니다.
- 되돌리기가 끝나면 `.agents/` 와 `asset_hashes.txt`·`version.txt` 가 같은 옛 버전을 가리킵니다. 그래서 다음 업데이트에서 반쯤 깔린 새 파일이 사용자 수정으로 잘못 판정되지 않습니다.
- `.mam` 이 사라지는 구간이 없으므로, 모니터가 잠금 파일이 사라진 것을 보고 저절로 꺼지는 일도 없습니다. 그래서 결정 2 의 명시적 정지가 필요합니다.
### 결정 4. `.mam_deploy/update.sh` 는 새 버전에서 뺀다
- 업데이트하는 길은 update 스킬 하나로 둡니다("상태를 바꾸는 인터페이스는 스킬을 거친다").
- 고칠 곳:
- `deploy/update.sh` 를 지웁니다.
- `install.sh`:
- 옛 위치 이전 코드(`for legacy_script in remove.sh update.sh`)에서 `update.sh` 를 뺍니다.
- `deploy/update.sh` 를 `.mam_deploy/update.sh` 로 복사하고 설치 목록에 넣는 블록을 지웁니다.
- 끝의 `bash .mam_deploy/update.sh` 안내를 update 스킬 안내로 바꿉니다.
- 제자리 설치에서 `.mam_deploy/update.sh` 가 있으면 지우고 설치 목록에서도 뺍니다. 지금 install.sh 는 stage 에 있을 때만 복사하고, 없다고 지우지는 않습니다.
- `deploy/install_mam.sh`: `update.sh` 복사와 설치 목록 기록을 같은 식으로 지웁니다.
- `remove.sh`: `.mam_deploy/update.sh` 를 지우는 줄은 그대로 둡니다. 옛 작업 공간을 정리하는 데 필요합니다.
- `deploy/gitea-ci.yml`: `shellcheck deploy/update.sh` 줄을 뺍니다. 그대로 두면 CI 가 실패합니다. update 스킬의 스크립트를 shellcheck 대상에 넣습니다.
- 문서(`deploy/README.md`, `deploy/INSTALL.md`, 루트 `README.md`·`README.ko.md` 의 설치 절과 저장소 구조 그림 등)는 update 스킬을 가리키게 고칩니다. `VERSIONS.md` 의 지난 릴리스 기록은 그대로 둡니다.
- **첫 전환은 옛 update.sh 로 한 번 진행합니다.** 설치된 작업 공간에 이미 있는 옛 파일은 고칠 수 없기 때문입니다.
- 옛 update.sh 는 사전 확인을 하지 않고, 모니터를 멈추지 않고, `.mam` 을 지운 뒤 목록만 되돌립니다. 이 일이 마지막으로 한 번 일어납니다.
- 이때 잃는 것: `herdr_workspace_id`, `diagnostics/`, 모니터 로그, `shim/`(lib.sh 가 다시 만듦), 디스크에 있다면 첫 댓글 5 의 `briefs/`·`resumelogs/`·`stoplogs/`, 그리고 `.mam` 밖의 `.cache/multi-agent-mux-monitor`. 릴리스 노트에 이 목록을 적습니다.
- 릴리스 노트에 다음 순서를 적습니다:
1. 루프가 끝나기를 기다리고, 열린 잡을 끝내거나 닫는다.
2. 세션을 모두 stop 한다.
3. 모니터(`reconcile.sh --subscribe`)를 멈춘다.
4. 옛 update.sh 를 실행한다.
5. 바로 update 스킬을 한 번 더 실행한다.
- 옛 update.sh 는 Gitea `main` 의 새 install.sh 로 설치하므로 update 스킬이 들어옵니다. 옛 remove.sh 는 `.mam_deploy/update.sh` 를 (설치 목록과 상관없이) 지우고, 새 install.sh 는 그것을 다시 만들지 않으므로 옛 파일은 남지 않습니다.
- 레지스트리 변환은 옛 update.sh 안에서는 일어나지 않습니다. 옛 update.sh 는 레지스트리를 파일로 복사만 하고, 그 복사는 변환하지 않습니다.
- 그래서 5 번에서 update 스킬을 한 번 더 실행해 진입점을 부릅니다. 레지스트리 변환이 이때 일어납니다.
- 5 번은 보통의 업데이트입니다. 설치할 버전을 고정하지 않으므로, 4 번과 5 번 사이에 `main` 이 바뀌면 다른 코드가 설치될 수 있습니다. 그래서 결정 1 의 거부, 결정 3 의 백업과 되돌리기가 모두 그대로 적용됩니다. `main` 이 그대로면 프레임워크 파일은 바뀌지 않습니다.
- 5 번을 하지 않아도 #71 의 "첫 쓰기 때 변환" 으로 결국 새 형식이 됩니다. 다만 그 시점은 업데이트 뒤 첫 레지스트리 쓰기(resume·create 등)입니다.
- 첫 전환 뒤에는 모니터가 없습니다. 3 번에서 멈췄고, 5 번의 스킬은 원래 떠 있지 않았으므로 띄우지 않습니다. resume 하면 결정 2 에 따라 뜹니다.
- 두 번째 업데이트부터는 새 스킬로만 진행합니다.
### 결정 5. 스킬이 자기 자신을 바꾸는 문제: 두 단계로 나눈다
- 옛 코드가 위험해지는 것은 **설치가 파일을 바꾸기 시작한 뒤** 부터입니다. 그래서 그 앞까지는 옛 코드의 규칙을 그대로 씁니다.
- **1단계 (옛 코드)**:
- 사전 확인(결정 1), 모니터 정지(결정 2), 되돌리기용 백업(결정 3)은 아직 바뀌지 않은 옛 코드(`lib.sh`·`lib_py`)의 함수와 진입점으로 합니다. 판정 규칙을 다시 구현하지 않기 위해서입니다(#71 의 "규칙 하나", #42 의 "절차는 코드로").
- 설치를 시작하기 전에 스킬은 업데이트 스크립트를 작업 트리 밖 임시 폴더로 복사하고, 그 복사본으로 실행을 넘깁니다. 복사본은 그 뒤로 `lib.sh`·`lib_py` 를 부르지 않습니다. 설치를 하고, 실패하면 되돌리기도 이 복사본이 합니다. 예외는 되돌리기가 끝난 뒤의 모니터 재기동입니다. 그때 트리는 다시 온전한 옛 버전이므로 그 트리의 `start_watchdog` 를 부릅니다(결정 2).
- 복사본은 macOS 의 bash 3.2 로 돌아야 합니다.
- **2단계 (새 코드, 새 프로세스)**:
- 설치가 성공하면 **새로 설치된** 코드의 진입점을 별도 프로세스로 실행합니다. 옛 코드는 새 코드를 import 하지 않습니다.
- 레지스트리 변환: YAML·DB 가 둘 다 없으면 진입점을 부르지 않고 "바꿀 것이 없음" 이라고 출력합니다. 진입점은 레지스트리가 없으면 스스로 1 로 끝나기 때문입니다. 있으면 첫 댓글 1 의 명령(`PYTHONPATH=.agents/skills`, `--yaml`)으로 부릅니다.
- 그다음 결정 2 대로 모니터를 복원하고, 변환 결과에 따라 종료 코드를 정합니다.
- Gitea 에서 새 버전의 업데이트 스크립트를 받아 실행하는 방식은 쓰지 않습니다. 새 스크립트가 모든 옛 설치를 상대해야 해 시험할 범위가 커지기 때문입니다.
### 결정 6. 지금 동작을 유지하는 것
| 항목 | 동작 |
|---|---|
| 설치할 버전 | Gitea `main` 의 `install.sh` (`MAM_INSTALLER_URL` 로 바꿀 수 있음) |
| 확인 질문 | `[y/N]` 으로 묻고, 터미널이 아니면 `-y`/`--yes` 가 필요하다. 지금의 별칭 `--force` 는 받지 않는다(결정 1 의 "강제 옵션 없음" 과 헷갈리지 않게). 에이전트는 `--yes` 를 붙여 부른다(결정 7) |
| 설치되지 않은 곳 | 설치 흔적(`.mam_deploy/remove.sh` 또는 루트의 `remove.sh`)이 없으면 거부한다. 개발용 git 체크아웃도 여기서 거부되고, 그쪽은 `git pull` 과 #71 의 "첫 쓰기 때 변환" 이 맡는다 |
| 레지스트리 변환 실패 | 첫 댓글 6 그대로: 설치는 유지하고 실패를 출력하며 0 이 아닌 값으로 끝난다. 모니터는 결정 2 대로 복원한다. 레지스트리가 없으면 건너뛴다(결정 5) |
### 결정 7. 업데이트를 부른 에이전트 자신은 거부 대상에서 뺀다
- herdr pane 안의 에이전트(이 작업 공간의 오케스트레이터 포함)가 스킬을 부르면, 그 에이전트 자신도 결정 1 의 "herdr 에 살아 있는 에이전트" 에 걸려 항상 거부됩니다. 그래서 부른 에이전트 하나만 뺍니다.
- 같은 에이전트로 묶어 빼는 것: 그 pane, 같은 세션의 레지스트리 `running` 행, 그 세션에 보낸 지금 실행 중인 잡 하나. 그 밖의 세션·잡·루프가 하나라도 있으면 그대로 거부합니다.
- 부른 에이전트는 **서버와 pane 을 함께** 써서 가려냅니다. 결정 1 은 모든 서버를 보는데, pane ID 와 에이전트 이름은 서버마다 따로이기 때문입니다.
- 서버는 `lib.sh` 의 `_herdr_caller_server`(`HERDR_SESSION`, 없으면 `HERDR_SOCKET_PATH` 의 모양)로, pane 은 `HERDR_PANE_ID` 로 정합니다.
- 그 서버의 에이전트 목록에서 그 pane 의 에이전트 이름을 찾고, 그 서버·이름의 행과 그 세션(`herdr:<name>`)에 보낸 잡을 묶습니다.
- 서버나 pane 을 정하지 못하면 아무것도 빼지 않습니다. 그러면 부른 에이전트 자신 때문에 거부되고, 그 이유를 출력합니다. 다른 서버의 같은 pane ID 는 빼지 않습니다.
- 그 에이전트의 훅(`.agents/hooks.json`)이 업데이트 중에 바뀌는 위험은 받아들입니다. 업데이트 스크립트 자체는 1단계에서 복사본으로 돌고, herdr 밖에서 도는 에이전트는 어느 방식으로도 가려낼 수 없어 같은 위험을 지기 때문입니다.
- 에이전트는 `--yes` 를 붙여 부릅니다.
### 결정 8. 업데이트 중에는 새 세션·루프·잡을 막는다
- 사전 확인과 설치 사이에 다른 터미널의 create·resume·`run_loop.sh`·delegate 가 새 세션이나 잡을 시작하면 결정 1 의 목적이 깨집니다.
- 업데이트는 사전 확인 **전에** `.mam/update-in-progress` 표식을 만들고, 2단계까지 끝난 뒤(성공·실패 모두) 지웁니다.
- 표식에는 업데이트의 pid·lstart 를 적습니다. 살아 있는지는 루프 잠금과 같은 방식(pid·lstart 비교)으로 판정합니다. 비정상 종료로 남은 표식(`stale`)은 막지 않고 "낡은 표식" 이라고만 출력합니다.
- create·resume·`run_loop.sh`·delegate submit 은 표식이 `live` 또는 `unknown` 이면 거부하고, 업데이트 중이라고 출력합니다.
- 표식을 한 번 읽는 것만으로는 막지 못합니다. create 가 표식이 없는 것을 본 뒤 spawn 전에 멈춰 있는 동안 업데이트가 표식을 만들고 사전 확인을 통과할 수 있습니다. 그래서 진입과 표식을 **입장 잠금** 하나로 맞춥니다.
- 입장 잠금은 `.mam/update-admission.lock` 의 BSD flock 입니다. `pane_lock` 과 같은 방식으로, 열어 둔 fd 에 `lib_py` 도우미가 flock 을 걸고, 대기 시간에 상한을 둡니다. 잠금은 그 fd 를 쥔 프로세스가 모두 끝나면 어떻게 끝나든 풀리므로 낡은 잠금이 남지 않습니다. macOS 에 `flock(1)` 이 없으므로 `fcntl.flock` 을 씁니다.
- **진입 쪽**(create·resume·`run_loop.sh`·delegate submit): 공유 잠금(`LOCK_SH`)을 잡고 표식을 확인합니다. 그 시작이 결정 1 의 사전 확인에 보이게 될 때까지 잠금을 쥡니다. create·resume 은 행을 기록할 때까지, `run_loop.sh` 는 루프 표식을 게시할 때까지, delegate submit 은 잡 기록을 남길 때까지입니다. 그다음 fd 를 닫습니다. delegate submit 은 대상 세션이 속한 작업 공간의 잠금을 씁니다. 오래 사는 자식(에이전트, 모니터, 루프 본체)에게 fd 를 물려주지 않습니다(`pane_lock` 과 같은 규칙).
- **업데이트 쪽**: 배타 잠금(`LOCK_EX`)을 잡은 채로 표식을 게시하고, 잠금을 놓은 뒤 사전 확인을 합니다. 배타 잠금은 이미 확인을 통과한 진입이 기록을 남길 때까지 기다리므로, 그 진입은 사전 확인에 반드시 보입니다. 표식을 게시한 뒤의 진입은 표식을 보고 거부됩니다. 상한 안에 잠금을 잡지 못하면 아무것도 바꾸지 않고 거부합니다.
- 표식 게시는 루프 잠금(`loop_lock.sh`)과 같이 원자적으로 합니다. 임시 파일을 hard link 로 게시하고, 이미 있으면 그 holder 를 판정합니다.
- `live`·`unknown` 이면 다른 업데이트가 진행 중이므로 아무것도 바꾸지 않고 거부합니다. 두 업데이트가 같은 트리를 동시에 바꾸지 않습니다.
- `stale` 일 때만 루프 잠금의 가져오기 절차로 넘겨받습니다.
- 다른 실행의 표식을 덮어쓰거나 지우지 않습니다. 해제는 표식의 pid·lstart 가 자기 것과 같을 때만 합니다.
- 복사본으로 넘길 때는 `exec` 로 넘깁니다. pid 와 lstart 가 그대로이므로 표식이 그 사이에 `stale` 로 보이는 구간이 없습니다. 2단계는 복사본의 자식 프로세스로 돌고, 복사본은 그것이 끝날 때까지 기다린 뒤 표식을 지웁니다.
### 결정 9. 새 버전에서 없어진 `.agents/` 아래 파일은 고치지 않은 것만 지운다
- 지금 install.sh 는 stage 에 있는 파일만 보고, 없어진 파일을 지우지 않습니다. 남은 옛 스킬 폴더는 에이전트가 스킬 목록으로 읽고, 남은 옛 훅이나 스크립트가 실행될 수도 있습니다. 결정 1 이 업데이트를 거부하는 이유와 같은 문제입니다.
- 옛 설치 목록과 `asset_hashes.txt` 에 있고 새 버전에는 없는 프레임워크 소유 파일 가운데, 해시가 옛 설치와 같은 것(사용자가 고치지 않은 것)만 지웁니다.
- 고친 파일은 `PRESERVE_CUSTOM` 처럼 남기고 목록을 출력합니다.
- 지운 파일은 `.mam/base/`·설치 목록·`asset_hashes.txt` 에서도 정리합니다.
- 지운 파일도 결정 3 의 백업 범위(`.agents/`)에 들어 있으므로, 실패하면 되돌아갑니다.
### 결정 10. `.venv` 도 백업하고, 실패하면 되돌린다
- 제자리 설치는 있는 `.venv` 를 재사용합니다. pip 자체만 `--upgrade` 로 올리고, 프로젝트 의존성은 `--upgrade` 없이 `pip install -r requirements.txt` 로 깝니다. 그래서 이미 명세를 만족하는 패키지는 그대로이고, 명세가 높아진 패키지만 올라가고, 새로 적힌 패키지는 깔리고, 명세에서 빠진 패키지는 지워지지 않습니다. 설치 중에 실패하면 `.venv` 가 일부만 바뀐 채 남을 수 있습니다.
- 그래서 `.venv` 를 결정 3 의 백업 범위에 넣고, 실패하면 그대로 되돌립니다. 같은 경로로 되돌리므로 `.venv` 안에 적힌 경로도 그대로 맞습니다.
- 디스크와 시간이 들지만, 조건 4 의 "업데이트 전 상태" 를 예외 없이 지키기 위해서입니다.
### 완료 조건 (본문을 고친 것)
- 조건 1 → "실행 중인 루프·열린 잡·실행 중인 세션(레지스트리 행이 없는 herdr 세션 포함)이 있으면 아무것도 바꾸지 않고 거부한다. 강제 옵션은 없다. 낡은 루프 표식과 `dry_run` 잡 기록은 거부 사유가 아니다."
- 조건 2 → "업데이트 뒤 `.mam` 의 설치기 관리 항목이 아닌 것은 모두 그대로 남는다(`.mam.env`, 레지스트리, 잡 기록, 위임 로그, `herdr_workspace_id` 등). `install_state` 는 키 단위로 합쳐지고, 같은 키는 한 줄이다." 이 조건은 update 스킬의 제자리 설치에 적용합니다. 첫 전환의 옛 update.sh 한 번은 결정 4 에 적은 목록 밖 항목을 잃고, 그 손실은 이 조건의 예외입니다.
- 조건 3 → "옛 형식 레지스트리가 update 스킬 실행 뒤 새 형식이 되고, YAML 과 DB 가 같다." 첫 전환(조건 5)에서는 릴리스 노트의 5 번 뒤에 성립한다.
- 조건 4 → "설치 중 실패하면 결정 3 의 백업 범위(`.venv` 포함)가 업데이트 전 상태로 돌아간다. 되돌린 뒤 `.agents/`·`asset_hashes.txt`·`version.txt` 가 같은 옛 버전을 가리키고, 업데이트 전에 없던 루트 파일은 남지 않으며, update 스킬을 다시 실행할 수 있다." 지금의 trap 은 이렇게 하지 않습니다. remove.sh 가 지운 `.agents/` 등을 되돌리지 못해, 코드도 `remove.sh` 도 없는 작업 공간을 남깁니다.
- 조건 5 → "옛 `.mam_deploy/update.sh` 로 업데이트하면 설치 결과에 update 스킬이 들어가고 옛 update.sh 는 남지 않는다. 그 뒤 update 스킬을 한 번 실행하면 레지스트리가 새 형식이 된다. 실행하지 않아도 첫 쓰기 때 새 형식이 된다."
- 조건 6 → "기존 테스트가 통과한다. macOS(bash 3.2, Python 3.9)에서도 통과한다." `.mam_deploy/update.sh` 를 실행하거나 그것이 깔리는지 보는 테스트는 `tests/test_issue8_update_state.py` 와 `tests/test_deploy_layout.py`(update.sh 를 다루는 두 시험) 둘입니다. 이 둘은 update 스킬을 부르고 `.mam_deploy/update.sh` 가 생기지 않는 것을 확인하도록 고칩니다. 지키려는 내용(#8 의 `install_state`·`version.txt`, 여러 번 업데이트해도 상태 보존)은 그대로 둡니다. `tests/test_deploy_freshness.py`(주석에만 update.sh 가 나옴)와 `tests/test_deploy_registry_merge.py`(hooks.json 병합)는 고치지 않습니다.
- 추가: "모니터가 떠 있던 작업 공간은 업데이트 뒤(성공·1단계 실패·변환 실패 모두) 모니터가 떠 있고, 없던 곳은 없다. `MAM_NO_SUBSCRIBER_DAEMON=1` 이면 다시 띄우지 않는다."
- 추가: "모니터가 시간 안에 끝나지 않으면 설치를 시작하지 않고, 결정 2 의 SIGKILL·재기동 경로를 따른다. 되돌리지 못하면 그렇게 출력한다."
- 추가: "레지스트리에 행이 없고, 환경에도 행에도 없는 서버에서 도는 에이전트가 이 작업 공간 안에 있으면 거부한다."
- 추가: "resume 이 모니터 데몬을 보장한다."
- 추가: "herdr pane 안의 에이전트가 부르면, 그 에이전트(pane·행·실행 중인 잡 하나)는 거부 사유가 아니다. 다른 세션·잡·루프가 있으면 거부한다."
- 추가: "업데이트 중에는 create·resume·`run_loop.sh`·delegate submit 이 거부된다. 업데이트가 비정상 종료해 남은 표식은 막지 않는다."
- 추가: 결정 8 의 경합을 시험한다.
- 표식이 없음을 본 뒤 spawn·행 기록 직전에 멈춘 create 와 업데이트가 겹치면, 업데이트는 그 create 가 행을 남길 때까지 기다린 뒤 그 세션 때문에 거부되거나, 상한을 넘어 거부된다. 어느 쪽이든 설치는 시작되지 않는다.
- 두 업데이트를 동시에 시작하면 하나만 진행하고, 다른 하나는 아무것도 바꾸지 않고 거부된다. 진행한 쪽의 표식은 덮이거나 지워지지 않는다.
- 복사본으로 넘기는 동안 들어온 delegate submit 은 거부된다(표식이 계속 `live`).
- 추가: "부른 에이전트는 서버와 pane 으로 가려낸다. 다른 서버에 같은 pane ID 가 있어도 부른 에이전트 하나만 빠지고, 서버나 pane 을 정하지 못하면 아무것도 빠지지 않는다."
- 추가: "새 버전에서 없어진 프레임워크 파일 가운데 고치지 않은 것은 지워지고, 고친 것은 남으며 그 목록이 출력된다."
- 추가: "레지스트리 백업은 `-wal`·`-shm` 에 남은 커밋까지 담고, 되돌린 뒤 DB 와 YAML 이 업데이트 전과 같다."
#71(v4.11.0)에서 기록되는 attach_command 는 herdr --session <server> agent attach <herdr 에 등록된 이름> 으로 바뀌었습니다. 그런데 delegate-job 래퍼가 잡을 보낸 뒤 출력하는 안내는 아직 옛 형식입니다.
HERDR_SESSION_NAME=$HERDR_SESSION_NAME herdr agent attach $sess — lib.sh를 source한 셸에서 실행
문제:
원래 세션 이름을 그대로 씁니다. 이름이 32자를 넘거나 대문자가 들어 있으면 herdr 에 그 이름의 에이전트가 없어 붙지 못합니다.
HERDR_SESSION_NAME 은 lib.sh 를 source 한 셸에서만 서버를 고릅니다. 실제 herdr 는 이 변수를 보지 않습니다.
고칠 방향: 그 세션 행의 attach_command 를 그대로 출력합니다. 행이 없으면 #71 과 같은 규칙(herdr --session <server> agent attach <변환된 이름>)으로 만듭니다. 규칙을 다시 구현하지 않고 #71 의 함수를 씁니다.
완료 조건 추가: "32자를 넘는 이름과 대문자가 든 이름의 세션으로 잡을 보내면, 출력되는 attach 안내가 그 행의 attach_command 와 같다."
## 함께 처리할 것: delegate-job 의 attach 안내 문구 (#71 후속)
#71(v4.11.0)에서 기록되는 `attach_command` 는 `herdr --session <server> agent attach <herdr 에 등록된 이름>` 으로 바뀌었습니다. 그런데 delegate-job 래퍼가 잡을 보낸 뒤 출력하는 안내는 아직 옛 형식입니다.
- 위치: `.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job:1021`
- `HERDR_SESSION_NAME=$HERDR_SESSION_NAME herdr agent attach $sess — lib.sh를 source한 셸에서 실행`
- 문제:
- 원래 세션 이름을 그대로 씁니다. 이름이 32자를 넘거나 대문자가 들어 있으면 herdr 에 그 이름의 에이전트가 없어 붙지 못합니다.
- `HERDR_SESSION_NAME` 은 lib.sh 를 source 한 셸에서만 서버를 고릅니다. 실제 `herdr` 는 이 변수를 보지 않습니다.
- 고칠 방향: 그 세션 행의 `attach_command` 를 그대로 출력합니다. 행이 없으면 #71 과 같은 규칙(`herdr --session <server> agent attach <변환된 이름>`)으로 만듭니다. 규칙을 다시 구현하지 않고 #71 의 함수를 씁니다.
- 완료 조건 추가: "32자를 넘는 이름과 대문자가 든 이름의 세션으로 잡을 보내면, 출력되는 attach 안내가 그 행의 `attach_command` 와 같다."
herdr session attach 는 에이전트가 아니라 herdr 서버(세션)에 붙는 명령입니다.
원래 세션 이름을 씁니다. 32자를 넘거나 대문자가 든 이름은 herdr 에 등록된 이름과 다릅니다.
#71 수동 확인에서 37자 이름으로 만든 세션의 안내가 이 형식이었습니다. 행에 기록된 attach_command(변환된 이름)로는 붙었습니다.
고칠 방향: 방금 쓴 행의 attach_command 를 그대로 출력합니다(delegate-job 안내와 같은 규칙).
완료 조건 추가: "create 가 끝에 출력하는 attach 안내가 그 행의 attach_command 와 같다(32자 초과·대문자 이름 포함)."
## 함께 처리할 것 (추가): create 의 attach 안내 문구 (#71 후속)
앞 댓글의 delegate-job 안내와 같은 종류의 문제가 create 에도 있습니다.
- 위치: `.agents/skills/multi-agent-mux-create/scripts/create_session.sh:770`
- `echo "Attach: herdr session attach $SESSION_NAME"`
- 문제:
- `herdr session attach` 는 에이전트가 아니라 herdr 서버(세션)에 붙는 명령입니다.
- 원래 세션 이름을 씁니다. 32자를 넘거나 대문자가 든 이름은 herdr 에 등록된 이름과 다릅니다.
- #71 수동 확인에서 37자 이름으로 만든 세션의 안내가 이 형식이었습니다. 행에 기록된 `attach_command`(변환된 이름)로는 붙었습니다.
- 고칠 방향: 방금 쓴 행의 `attach_command` 를 그대로 출력합니다(delegate-job 안내와 같은 규칙).
- 완료 조건 추가: "create 가 끝에 출력하는 attach 안내가 그 행의 `attach_command` 와 같다(32자 초과·대문자 이름 포함)."
구현 전에 main(v4.11.0)의 코드를 확인하고, 921의 결정 1–10에 다음 네 가지를 더합니다. 921과 다른 부분은 이 댓글을 따릅니다.
U1. attach 안내 3곳도 함께 수정
926/930과 같은 방식으로 다음 세 곳도 고칩니다.
stop_session.sh의 TUI 멈춤 안내 (herdr session attach ...)
deploy/INSTALL.md의 예시 (herdr session attach ...)
deploy/install_mam.sh의 설치 완료 안내 (HERDR_SERVER_NAME=... herdr agent attach ...)
스크립트는 행의 attach_command를 출력합니다. 행이 없으면 #71의 lib_py/agents/sanitize.pyattach_command로 만듭니다. 문서는 herdr --session <server> agent attach <herdr 이름> 형식을 보여 줍니다.
U2. stop과 orc-onboard도 업데이트 중에는 거부
둘 다 레지스트리에 씁니다. 업데이트 1단계가 롤백되면 그 사이에 쓴 내용이 레지스트리 백업으로 덮여 사라집니다. 예를 들어 --purge-conversation으로 대화 파일을 지운 행이 되살아날 수 있습니다.
그래서 create/resume/run_loop/delegate submit과 같은 입장 잠금(공유)과 update-in-progress 표식을 씁니다. 표식이 live/unknown이면 거부하고, 레지스트리 쓰기를 마칠 때까지 잠금을 쥡니다.
status 같은 읽기 전용 명령은 막지 않습니다.
업데이트는 stop을 부르지 않으므로, 결정 7의 호출자 제외는 여기에 해당하지 않습니다.
U3. 스킬이 띄우지 않은 모니터가 잠금을 쥐고 있으면 거부
데몬은 start_watchdog이 띄운 형태(daemon.find_running의 패턴)로만 찾을 수 있습니다. 다른 경로로 직접 띄운 모니터는 보이지 않습니다.
사전 확인에서 .mam/monitor.lock이 잡혀 있는데 스킬이 띄운 데몬을 찾지 못하면, 아무것도 바꾸기 전에 거부합니다. 스킬이 띄우지 않은 모니터가 잠금을 쥐고 있다는 것과 정리 방법을 안내합니다.
브로커가 없어 폴링 모드로 도는 수동 모니터는 잠금을 쥐지 않아 찾을 수 없습니다. 이 경우는 받아들이고 다루지 않습니다.
U4. 백업 위치와 남은 백업 처리
롤백용 백업은 작업 공간과 같은 파일 시스템의 .mam/update-backup/에 둡니다. /tmp는 작은 tmpfs일 수 있고, .venv와 .agents/ 복사본은 inode를 많이 씁니다.
업데이트가 끝나면 지웁니다. 성공했을 때, 1단계 롤백을 마쳤을 때, 설치는 됐지만 레지스트리 변환이 실패했을 때 모두 해당합니다.
.mam이나 트리를 복사·해시하는 모든 곳에서 .mam/update-backup/을 뺍니다.
진행 단계는 백업 폴더의 상태 파일에 원자적으로 기록합니다: backing-up → installing(설치가 파일을 바꾸기 직전) → installed(설치 끝).
비정상 종료로 남은 백업(주인이 없어 표식이 stale이고 넘겨받은 경우)은 다음 업데이트가 처리합니다. 처리 시점은 사전 확인(결정 1)과 모니터 정지(결정 2) 뒤, 새 백업을 만들기 전입니다.
남은 백업의 상태
처리
backing-up 또는 상태 파일 없음
아직 아무것도 바뀌지 않았으므로 지우고 진행
installing
설치 도중 죽은 것이므로 같은 롤백 절차로 되돌리고, 이전 업데이트를 되돌렸다고 알린 뒤 지우고 진행. 레지스트리는 되돌리지 않습니다. 1단계는 레지스트리를 바꾸지 않고, 죽은 뒤에 쓴 내용이 들어 있을 수 있기 때문입니다
installed
설치는 끝났으므로 지우고 진행 (레지스트리는 다음 쓰기나 이번 실행의 2단계에서 변환)
되돌리기가 실패하면 더 바꾸지 않고, 남은 백업을 그대로 둔 채 무엇이 실패했는지 알리고 0이 아닌 코드로 끝냅니다.
범위 밖
네트워크 파일 시스템 거부는 #74에서 다룹니다.
## 코드 확인 후 더 정한 것 (U1–U4)
구현 전에 main(v4.11.0)의 코드를 확인하고, 921의 결정 1–10에 다음 네 가지를 더합니다. 921과 다른 부분은 이 댓글을 따릅니다.
### U1. attach 안내 3곳도 함께 수정
926/930과 같은 방식으로 다음 세 곳도 고칩니다.
- `stop_session.sh`의 TUI 멈춤 안내 (`herdr session attach ...`)
- `deploy/INSTALL.md`의 예시 (`herdr session attach ...`)
- `deploy/install_mam.sh`의 설치 완료 안내 (`HERDR_SERVER_NAME=... herdr agent attach ...`)
스크립트는 행의 `attach_command`를 출력합니다. 행이 없으면 #71의 `lib_py/agents/sanitize.py` `attach_command`로 만듭니다. 문서는 `herdr --session <server> agent attach <herdr 이름>` 형식을 보여 줍니다.
### U2. stop과 orc-onboard도 업데이트 중에는 거부
둘 다 레지스트리에 씁니다. 업데이트 1단계가 롤백되면 그 사이에 쓴 내용이 레지스트리 백업으로 덮여 사라집니다. 예를 들어 `--purge-conversation`으로 대화 파일을 지운 행이 되살아날 수 있습니다.
- 그래서 create/resume/run_loop/delegate submit과 같은 입장 잠금(공유)과 `update-in-progress` 표식을 씁니다. 표식이 `live`/`unknown`이면 거부하고, 레지스트리 쓰기를 마칠 때까지 잠금을 쥡니다.
- status 같은 읽기 전용 명령은 막지 않습니다.
- 업데이트는 stop을 부르지 않으므로, 결정 7의 호출자 제외는 여기에 해당하지 않습니다.
### U3. 스킬이 띄우지 않은 모니터가 잠금을 쥐고 있으면 거부
데몬은 `start_watchdog`이 띄운 형태(`daemon.find_running`의 패턴)로만 찾을 수 있습니다. 다른 경로로 직접 띄운 모니터는 보이지 않습니다.
- 사전 확인에서 `.mam/monitor.lock`이 잡혀 있는데 스킬이 띄운 데몬을 찾지 못하면, 아무것도 바꾸기 전에 거부합니다. 스킬이 띄우지 않은 모니터가 잠금을 쥐고 있다는 것과 정리 방법을 안내합니다.
- 브로커가 없어 폴링 모드로 도는 수동 모니터는 잠금을 쥐지 않아 찾을 수 없습니다. 이 경우는 받아들이고 다루지 않습니다.
### U4. 백업 위치와 남은 백업 처리
- 롤백용 백업은 작업 공간과 같은 파일 시스템의 `.mam/update-backup/`에 둡니다. `/tmp`는 작은 tmpfs일 수 있고, `.venv`와 `.agents/` 복사본은 inode를 많이 씁니다.
- 업데이트가 끝나면 지웁니다. 성공했을 때, 1단계 롤백을 마쳤을 때, 설치는 됐지만 레지스트리 변환이 실패했을 때 모두 해당합니다.
- `.mam`이나 트리를 복사·해시하는 모든 곳에서 `.mam/update-backup/`을 뺍니다.
- 진행 단계는 백업 폴더의 상태 파일에 원자적으로 기록합니다: `backing-up` → `installing`(설치가 파일을 바꾸기 직전) → `installed`(설치 끝).
비정상 종료로 남은 백업(주인이 없어 표식이 `stale`이고 넘겨받은 경우)은 다음 업데이트가 처리합니다. 처리 시점은 사전 확인(결정 1)과 모니터 정지(결정 2) 뒤, 새 백업을 만들기 전입니다.
| 남은 백업의 상태 | 처리 |
|---|---|
| `backing-up` 또는 상태 파일 없음 | 아직 아무것도 바뀌지 않았으므로 지우고 진행 |
| `installing` | 설치 도중 죽은 것이므로 같은 롤백 절차로 되돌리고, 이전 업데이트를 되돌렸다고 알린 뒤 지우고 진행. **레지스트리는 되돌리지 않습니다.** 1단계는 레지스트리를 바꾸지 않고, 죽은 뒤에 쓴 내용이 들어 있을 수 있기 때문입니다 |
| `installed` | 설치는 끝났으므로 지우고 진행 (레지스트리는 다음 쓰기나 이번 실행의 2단계에서 변환) |
되돌리기가 실패하면 더 바꾸지 않고, 남은 백업을 그대로 둔 채 무엇이 실패했는지 알리고 0이 아닌 코드로 끝냅니다.
### 범위 밖
- 네트워크 파일 시스템 거부는 #74에서 다룹니다.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
배경
#71 에서 "상태를 바꾸는 인터페이스는 반드시 스킬을 거친다" 를 원칙으로 정했습니다(시작은 create·resume, 종료는 stop). 지금 MAM 버전 업데이트는 스킬이 아니라 설치된 작업 공간의
.mam_deploy/update.sh(원본deploy/update.sh)로 합니다.#71 은 레지스트리 형식을 바꿉니다.
kill_command·start_command를 지우고,attach_command를 새 형태로 바꾸고, 형식 버전을 추가합니다. 그래서lib_py에 마이그레이션 함수를 두고, 레지스트리를 읽을 때 옛 형식이면 한 번 실행합니다. 업데이트가 끝난 직후에 이 함수를 명시적으로 부르는 일은 이 이슈로 넘겼습니다.지금의 update.sh
.mam.env(또는.env)와.mam의 일부(agent-sessions.*,jobs/,delegate_job_logs/,install_manifest.txt,install_state·asset_hashes.txt·version.txt,skill-backups/)를 임시 폴더에 복사합니다.remove.sh --force로 기존 설치를 지웁니다..agents/, 문서,.venv,.mam이 지워집니다.install.sh를 받아(MAM_INSTALLER_URL로 바꿀 수 있음) 다시 설치합니다.install_state는 키를 합칩니다(#8). 실패하면 trap 이 전부 되돌립니다.레지스트리 내용은 복사만 하고 변환하지는 않습니다.
문제
.mam을 지웠다가 되돌립니다. 그 사이 레지스트리나 잡 기록에 쓰인 내용은 사라질 수 있습니다..venv·.agents/도 실행 중에 바뀝니다..mam파일은 지워진 뒤 돌아오지 않습니다. 예를 들어herdr_workspace_id는lib.sh:328에서 씁니다. 무엇을 보존해야 하는지 확인이 필요합니다.제안
multi-agent-mux-update스킬을 만들고 update.sh 가 하던 일을 그 안으로 옮깁니다..mam.env보존,install_state합치기, 실패 시 되돌리기). 보존 목록은 위 문제 4 를 확인해 정합니다..agents/가 바뀌므로, 옛 코드가 실행되지 않게 순서를 지킵니다..mam_deploy/update.sh는 스킬을 부르는 얇은 진입점으로 남길지, 지울지 정합니다.git pull). 그쪽 마이그레이션은 #71 의 "읽을 때 한 번" 이 맡습니다.완료 조건
.mam.env, 레지스트리(YAML·DB), 잡 기록, 위임 로그가 보존된다.install_state는 지금처럼 합쳐진다.kill_command·start_command가 있는 행)가 업데이트 뒤 새 형식이 되고, YAML 과 DB 가 같다..mam_deploy/update.sh로 시작한 업데이트도 새 스킬과 마이그레이션까지 도달한다.tests/test_issue8_update_state.py,test_deploy_*.py)가 통과한다. macOS(bash 3.2, Python 3.9)에서도 통과한다.관련
install_state·version.txt처리#71 작업 결과를 반영한 보충
#71 구현(PR #73)에서 정해진 내용과, 그 과정에서 정한 전제를 정리합니다. 본문과 다른 곳은 이 댓글을 따릅니다.
1. 마이그레이션 진입점 (#71 이 제공)
lib_py/atomic_yaml.py)를 거쳐 실행합니다. 평소 쓰기와 같은 잠금을 잡고, DB 와 YAML 을 함께 다시 씁니다.registry-format: <이전> -> <이후>, rows changed: <n>registry_format입니다. 키가 없으면 1, 현재는 2 입니다.lib_py/registry_format.py의 모듈 docstring 한 곳에 적혀 있습니다. 업데이트 스킬은 이 진입점만 부르고, 형식의 세부 내용에 기대지 않습니다.2. 본문 정정: "읽을 때 한 번" 이 아니라 "첫 쓰기 때 한 번"
load_state_json, status)를 잠금 없는 읽기 전용으로 두고, 쓰기 함수 안에서 잠금을 잡은 뒤 변환합니다. 따라서 첫 쓰기 전까지는 status 에 옛 형식 행이 보일 수 있습니다. 모니터가 곧 쓰기를 하므로 그 기간은 짧습니다.3. 전제:
.mam은 로컬 디스크에 둔다 (NFS 등은 지원하지 않음).mam은 공식 지원하지 않습니다..mam을 코드로 거부하는 일은 #74 에서 합니다. 거부는 레지스트리 쓰기 함수 안에서 하므로, 업데이트 스킬이 부르는 진입점도 따로 손대지 않아도 그 판정을 거치게 됩니다.4. 순서
.agents/skills로 진입점 실행 (옛 코드가 돌지 않게, 설치가 끝난 뒤에만)agent-sessions.*를 파일 단위로 복사하므로, 복사 도중 쓰기가 일어나면 DB 와 WAL 이 서로 다른 시점의 것일 수 있습니다.5. 백업 목록 확인 대상 (본문 문제 4)
지금 update.sh 가 복사하지 않는
.mam항목입니다. 각각 보존 / 재생성 / 버림으로 나눠야 합니다.herdr_workspace_idlib.sh가 읽고 씀)briefs/resumelogs/,stoplogs/diagnostics/multi-agent-mux-monitor.logshim/*.lock(agent-sessions.db.lock,monitor.lock등)6. 정한 것: 진입점이 실패했을 때
진입점은 레지스트리를 새 형식으로 바꾸지 못하면 0 이 아닌 값으로 끝납니다. 예를 들면 YAML·DB 를 읽지 못할 때, 디스크가 가득 찼거나 권한이 없을 때, 다른 쓰기가 잠금을 오래 잡고 있을 때, 새
.venv에 의존성이 빠졌을 때입니다.구현 전에 정한 것 (결정 1–10)
본문과 첫 댓글의 열린 항목을 하나씩 정했습니다. 본문·첫 댓글과 다른 곳은 이 댓글을 따릅니다. 특히 첫 댓글 4 의 "백업 → 제거 → 설치 → 복원" 에서 제거와 복원 단계는 없어집니다(결정 3). 결정 7–10 은 결정 1–6 을 검토하면서 새로 나온 항목입니다.
결정 1. 업데이트를 거부하는 조건: 하나라도 있으면 거부, 강제 옵션 없음
.mam/loop-guard-active가 있고, 루프 잠금의 holder 판정(loop_lock.sh의 pid·lstart 비교)이live또는unknown인 것.stale이면 막지 않고 "낡은 표식" 이라고만 출력합니다.completed·error·cancelled)되지 않은 잡 기록.dry_run: true기록은 세지 않습니다. 읽을 수 없는 JSON 은 열린 잡으로 봅니다..mam/jobs(와MAM_JOBS_DIR)는 모든 기록을 봅니다.control_plane.mam_jobs_dir처럼 다른 곳의 잡 디렉터리는, 이 작업 공간의 행(herdr:<name>)에 보낸 기록만 봅니다. 지금의worktree.pyopen_jobs는.mam/jobs쪽에서도 행에 보낸 기록만 보므로, 그대로 쓰면 이 결정보다 좁습니다.running행. 레지스트리는 지금 코드와 같이 DB 를 먼저, 없으면 YAML 을 읽습니다. 읽을 수 없으면 거부합니다. 그래서 오류를 삼키고{}를 돌려주는 파이썬state.load_state_json이 아니라, 읽지 못하면 1 로 끝나는 bashload_state_json strict를 씁니다.herdr session list --json이running으로 보고하는 서버 전부입니다. 지금 shim 도 서버가 떠 있는지 이 명령으로 봅니다.default서버도 이 목록에 나옵니다(herdr 0.9.5 에서 확인). 그래서 임의의--herdr-session으로 띄운 뒤 행이 기록되기 전에 멈춘 세션도 찾습니다.MAM_NO_AUTOSTART=1과 같은 규칙).registry.py close --job <id> --request cancelled로 닫습니다. 읽을 수 없는 잡 JSON 은 그 파일 경로를 출력하고, 확인한 뒤 손으로 옮기거나 지우도록 안내합니다.결정 2. 모니터 데몬: 업데이트 전 상태로 돌려놓는다
.mam/monitor.lock을 잡을 수 있게 된 때입니다.monitor.lock을 잡을 수 있는지 다시 확인합니다.start_watchdog로 모니터를 다시 띄우고 실패로 끝냅니다. 원래 떠 있던 상태로 돌아갑니다.start_watchdog로 다시 띄웁니다(결정 5 의 예외). create 가 쓰는 같은 함수라서 띄우는 방식(절대 경로, 작업 공간 cwd, 부른 쪽 프로세스 그룹에서 분리,.mam/multi-agent-mux-monitor.log에 기록,--idle-timeout 0)이 같습니다. 그래서 다음 업데이트의 정지 단계와 create·resume 이 이 데몬을 찾습니다.reconcile.sh를 직접 실행하면 데몬을 찾는pgrep패턴(절대 경로)과 맞지 않아 다음 업데이트가 모니터를 멈추지 못합니다.MAM_NO_SUBSCRIBER_DAEMON=1이면 원래 떠 있었더라도 다시 띄우지 않고, 그렇게 했다고 출력합니다. 플래그가 우선합니다. 지금의daemon.py도 이 값이면 새로 띄우지 않습니다.start_watchdog)해서 업데이트 뒤에 모니터가 없는 구간을 메웁니다. 이미 살아 있는 세션을 다시 등록하는 경로와 새로 띄우는 경로 모두 해당합니다.결정 3.
.mam은 지우지 않는다: 제자리 설치.mam을 통째로 지운 뒤(remove.sh) 정해진 목록만 되돌립니다. 그래서 목록에 없는 항목이 업데이트 때 사라집니다. 예:herdr_workspace_id,base/(아래),diagnostics/, 모니터 로그, 그리고 디스크에 있다면 첫 댓글 5 의briefs/·resumelogs/·stoplogs/.remove.sh를 부르지 않고, 새install.sh를 제자리에서 실행합니다..mam은 남겨 두고, 설치기가 관리하는 항목만 새로 씁니다:install_manifest.txt,install_state,asset_hashes.txt,version.txt,base/,skill-backups/..agents/아래 프레임워크 소유 파일마다 판정합니다(asset_hashes.txt기준).PRESERVE_CUSTOM). merge 하지 않습니다..agents/hooks.json하나만.mam/base/의 upstream 사본을 기준으로 키 단위 3-way merge 합니다.base/에는 이 파일의 사본만 들어갑니다.agent-sessions.yaml·.db)는 설치기가 건드리지 않습니다.install_state는 키 단위로 합칩니다(#8). 같은 키는 한 줄만 남습니다. 지금 install.sh 는 파일이 있으면gitignore_created=1을 덧붙이기만 하므로, 제자리 설치에서는 두 줄이 되고remove.sh가 그 값을1로 읽지 못합니다. 새 install.sh 에서 같은 키는 덮어쓰게 고칩니다..mam안에서 옛 버전이 남긴 쓰지 않는 파일은 그대로 둡니다. 지워서 생기는 손실보다 남아서 드는 비용이 작다고 봅니다..agents/아래에서 없어진 파일은 다르게 다룹니다(결정 9)..agents/.mam_deploy/AGENTS.md등의 루트 문서,.mam.env.example,.mam.env)과.gitignore. 업데이트 전에 없던 파일은 되돌릴 때 지웁니다..mam의 설치기 관리 항목(위 목록).venv(결정 10)agent-sessions.db.lock)을 잡고 뜹니다. DB 는 SQLite backup API 로 복사합니다. 파일 단위 복사로-wal·-shm과 시점이 어긋나는 일을 막기 위해서입니다. 되돌릴 때는 DB 를 백업으로 바꾸고 남은-wal·-shm을 지웁니다. macOS 는 닫은 뒤에도 이 파일들을 남깁니다..agents/와asset_hashes.txt·version.txt가 같은 옛 버전을 가리킵니다. 그래서 다음 업데이트에서 반쯤 깔린 새 파일이 사용자 수정으로 잘못 판정되지 않습니다..mam이 사라지는 구간이 없으므로, 모니터가 잠금 파일이 사라진 것을 보고 저절로 꺼지는 일도 없습니다. 그래서 결정 2 의 명시적 정지가 필요합니다.결정 4.
.mam_deploy/update.sh는 새 버전에서 뺀다deploy/update.sh를 지웁니다.install.sh:for legacy_script in remove.sh update.sh)에서update.sh를 뺍니다.deploy/update.sh를.mam_deploy/update.sh로 복사하고 설치 목록에 넣는 블록을 지웁니다.bash .mam_deploy/update.sh안내를 update 스킬 안내로 바꿉니다..mam_deploy/update.sh가 있으면 지우고 설치 목록에서도 뺍니다. 지금 install.sh 는 stage 에 있을 때만 복사하고, 없다고 지우지는 않습니다.deploy/install_mam.sh:update.sh복사와 설치 목록 기록을 같은 식으로 지웁니다.remove.sh:.mam_deploy/update.sh를 지우는 줄은 그대로 둡니다. 옛 작업 공간을 정리하는 데 필요합니다.deploy/gitea-ci.yml:shellcheck deploy/update.sh줄을 뺍니다. 그대로 두면 CI 가 실패합니다. update 스킬의 스크립트를 shellcheck 대상에 넣습니다.deploy/README.md,deploy/INSTALL.md, 루트README.md·README.ko.md의 설치 절과 저장소 구조 그림 등)는 update 스킬을 가리키게 고칩니다.VERSIONS.md의 지난 릴리스 기록은 그대로 둡니다..mam을 지운 뒤 목록만 되돌립니다. 이 일이 마지막으로 한 번 일어납니다.herdr_workspace_id,diagnostics/, 모니터 로그,shim/(lib.sh 가 다시 만듦), 디스크에 있다면 첫 댓글 5 의briefs/·resumelogs/·stoplogs/, 그리고.mam밖의.cache/multi-agent-mux-monitor. 릴리스 노트에 이 목록을 적습니다.reconcile.sh --subscribe)를 멈춘다.main의 새 install.sh 로 설치하므로 update 스킬이 들어옵니다. 옛 remove.sh 는.mam_deploy/update.sh를 (설치 목록과 상관없이) 지우고, 새 install.sh 는 그것을 다시 만들지 않으므로 옛 파일은 남지 않습니다.main이 바뀌면 다른 코드가 설치될 수 있습니다. 그래서 결정 1 의 거부, 결정 3 의 백업과 되돌리기가 모두 그대로 적용됩니다.main이 그대로면 프레임워크 파일은 바뀌지 않습니다.결정 5. 스킬이 자기 자신을 바꾸는 문제: 두 단계로 나눈다
lib.sh·lib_py)의 함수와 진입점으로 합니다. 판정 규칙을 다시 구현하지 않기 위해서입니다(#71 의 "규칙 하나", #42 의 "절차는 코드로").lib.sh·lib_py를 부르지 않습니다. 설치를 하고, 실패하면 되돌리기도 이 복사본이 합니다. 예외는 되돌리기가 끝난 뒤의 모니터 재기동입니다. 그때 트리는 다시 온전한 옛 버전이므로 그 트리의start_watchdog를 부릅니다(결정 2).PYTHONPATH=.agents/skills,--yaml)으로 부릅니다.결정 6. 지금 동작을 유지하는 것
main의install.sh(MAM_INSTALLER_URL로 바꿀 수 있음)[y/N]으로 묻고, 터미널이 아니면-y/--yes가 필요하다. 지금의 별칭--force는 받지 않는다(결정 1 의 "강제 옵션 없음" 과 헷갈리지 않게). 에이전트는--yes를 붙여 부른다(결정 7).mam_deploy/remove.sh또는 루트의remove.sh)이 없으면 거부한다. 개발용 git 체크아웃도 여기서 거부되고, 그쪽은git pull과 #71 의 "첫 쓰기 때 변환" 이 맡는다결정 7. 업데이트를 부른 에이전트 자신은 거부 대상에서 뺀다
running행, 그 세션에 보낸 지금 실행 중인 잡 하나. 그 밖의 세션·잡·루프가 하나라도 있으면 그대로 거부합니다.lib.sh의_herdr_caller_server(HERDR_SESSION, 없으면HERDR_SOCKET_PATH의 모양)로, pane 은HERDR_PANE_ID로 정합니다.herdr:<name>)에 보낸 잡을 묶습니다..agents/hooks.json)이 업데이트 중에 바뀌는 위험은 받아들입니다. 업데이트 스크립트 자체는 1단계에서 복사본으로 돌고, herdr 밖에서 도는 에이전트는 어느 방식으로도 가려낼 수 없어 같은 위험을 지기 때문입니다.--yes를 붙여 부릅니다.결정 8. 업데이트 중에는 새 세션·루프·잡을 막는다
run_loop.sh·delegate 가 새 세션이나 잡을 시작하면 결정 1 의 목적이 깨집니다..mam/update-in-progress표식을 만들고, 2단계까지 끝난 뒤(성공·실패 모두) 지웁니다.stale)은 막지 않고 "낡은 표식" 이라고만 출력합니다.run_loop.sh·delegate submit 은 표식이live또는unknown이면 거부하고, 업데이트 중이라고 출력합니다..mam/update-admission.lock의 BSD flock 입니다.pane_lock과 같은 방식으로, 열어 둔 fd 에lib_py도우미가 flock 을 걸고, 대기 시간에 상한을 둡니다. 잠금은 그 fd 를 쥔 프로세스가 모두 끝나면 어떻게 끝나든 풀리므로 낡은 잠금이 남지 않습니다. macOS 에flock(1)이 없으므로fcntl.flock을 씁니다.run_loop.sh·delegate submit): 공유 잠금(LOCK_SH)을 잡고 표식을 확인합니다. 그 시작이 결정 1 의 사전 확인에 보이게 될 때까지 잠금을 쥡니다. create·resume 은 행을 기록할 때까지,run_loop.sh는 루프 표식을 게시할 때까지, delegate submit 은 잡 기록을 남길 때까지입니다. 그다음 fd 를 닫습니다. delegate submit 은 대상 세션이 속한 작업 공간의 잠금을 씁니다. 오래 사는 자식(에이전트, 모니터, 루프 본체)에게 fd 를 물려주지 않습니다(pane_lock과 같은 규칙).LOCK_EX)을 잡은 채로 표식을 게시하고, 잠금을 놓은 뒤 사전 확인을 합니다. 배타 잠금은 이미 확인을 통과한 진입이 기록을 남길 때까지 기다리므로, 그 진입은 사전 확인에 반드시 보입니다. 표식을 게시한 뒤의 진입은 표식을 보고 거부됩니다. 상한 안에 잠금을 잡지 못하면 아무것도 바꾸지 않고 거부합니다.loop_lock.sh)과 같이 원자적으로 합니다. 임시 파일을 hard link 로 게시하고, 이미 있으면 그 holder 를 판정합니다.live·unknown이면 다른 업데이트가 진행 중이므로 아무것도 바꾸지 않고 거부합니다. 두 업데이트가 같은 트리를 동시에 바꾸지 않습니다.stale일 때만 루프 잠금의 가져오기 절차로 넘겨받습니다.exec로 넘깁니다. pid 와 lstart 가 그대로이므로 표식이 그 사이에stale로 보이는 구간이 없습니다. 2단계는 복사본의 자식 프로세스로 돌고, 복사본은 그것이 끝날 때까지 기다린 뒤 표식을 지웁니다.결정 9. 새 버전에서 없어진
.agents/아래 파일은 고치지 않은 것만 지운다asset_hashes.txt에 있고 새 버전에는 없는 프레임워크 소유 파일 가운데, 해시가 옛 설치와 같은 것(사용자가 고치지 않은 것)만 지웁니다.PRESERVE_CUSTOM처럼 남기고 목록을 출력합니다..mam/base/·설치 목록·asset_hashes.txt에서도 정리합니다..agents/)에 들어 있으므로, 실패하면 되돌아갑니다.결정 10.
.venv도 백업하고, 실패하면 되돌린다.venv를 재사용합니다. pip 자체만--upgrade로 올리고, 프로젝트 의존성은--upgrade없이pip install -r requirements.txt로 깝니다. 그래서 이미 명세를 만족하는 패키지는 그대로이고, 명세가 높아진 패키지만 올라가고, 새로 적힌 패키지는 깔리고, 명세에서 빠진 패키지는 지워지지 않습니다. 설치 중에 실패하면.venv가 일부만 바뀐 채 남을 수 있습니다..venv를 결정 3 의 백업 범위에 넣고, 실패하면 그대로 되돌립니다. 같은 경로로 되돌리므로.venv안에 적힌 경로도 그대로 맞습니다.완료 조건 (본문을 고친 것)
dry_run잡 기록은 거부 사유가 아니다.".mam의 설치기 관리 항목이 아닌 것은 모두 그대로 남는다(.mam.env, 레지스트리, 잡 기록, 위임 로그,herdr_workspace_id등).install_state는 키 단위로 합쳐지고, 같은 키는 한 줄이다." 이 조건은 update 스킬의 제자리 설치에 적용합니다. 첫 전환의 옛 update.sh 한 번은 결정 4 에 적은 목록 밖 항목을 잃고, 그 손실은 이 조건의 예외입니다..venv포함)가 업데이트 전 상태로 돌아간다. 되돌린 뒤.agents/·asset_hashes.txt·version.txt가 같은 옛 버전을 가리키고, 업데이트 전에 없던 루트 파일은 남지 않으며, update 스킬을 다시 실행할 수 있다." 지금의 trap 은 이렇게 하지 않습니다. remove.sh 가 지운.agents/등을 되돌리지 못해, 코드도remove.sh도 없는 작업 공간을 남깁니다..mam_deploy/update.sh로 업데이트하면 설치 결과에 update 스킬이 들어가고 옛 update.sh 는 남지 않는다. 그 뒤 update 스킬을 한 번 실행하면 레지스트리가 새 형식이 된다. 실행하지 않아도 첫 쓰기 때 새 형식이 된다.".mam_deploy/update.sh를 실행하거나 그것이 깔리는지 보는 테스트는tests/test_issue8_update_state.py와tests/test_deploy_layout.py(update.sh 를 다루는 두 시험) 둘입니다. 이 둘은 update 스킬을 부르고.mam_deploy/update.sh가 생기지 않는 것을 확인하도록 고칩니다. 지키려는 내용(#8 의install_state·version.txt, 여러 번 업데이트해도 상태 보존)은 그대로 둡니다.tests/test_deploy_freshness.py(주석에만 update.sh 가 나옴)와tests/test_deploy_registry_merge.py(hooks.json 병합)는 고치지 않습니다.MAM_NO_SUBSCRIBER_DAEMON=1이면 다시 띄우지 않는다."run_loop.sh·delegate submit 이 거부된다. 업데이트가 비정상 종료해 남은 표식은 막지 않는다."live).-wal·-shm에 남은 커밋까지 담고, 되돌린 뒤 DB 와 YAML 이 업데이트 전과 같다."함께 처리할 것: delegate-job 의 attach 안내 문구 (#71 후속)
#71(v4.11.0)에서 기록되는
attach_command는herdr --session <server> agent attach <herdr 에 등록된 이름>으로 바뀌었습니다. 그런데 delegate-job 래퍼가 잡을 보낸 뒤 출력하는 안내는 아직 옛 형식입니다..agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job:1021HERDR_SESSION_NAME=$HERDR_SESSION_NAME herdr agent attach $sess — lib.sh를 source한 셸에서 실행HERDR_SESSION_NAME은 lib.sh 를 source 한 셸에서만 서버를 고릅니다. 실제herdr는 이 변수를 보지 않습니다.attach_command를 그대로 출력합니다. 행이 없으면 #71 과 같은 규칙(herdr --session <server> agent attach <변환된 이름>)으로 만듭니다. 규칙을 다시 구현하지 않고 #71 의 함수를 씁니다.attach_command와 같다."함께 처리할 것 (추가): create 의 attach 안내 문구 (#71 후속)
앞 댓글의 delegate-job 안내와 같은 종류의 문제가 create 에도 있습니다.
.agents/skills/multi-agent-mux-create/scripts/create_session.sh:770echo "Attach: herdr session attach $SESSION_NAME"herdr session attach는 에이전트가 아니라 herdr 서버(세션)에 붙는 명령입니다.attach_command(변환된 이름)로는 붙었습니다.attach_command를 그대로 출력합니다(delegate-job 안내와 같은 규칙).attach_command와 같다(32자 초과·대문자 이름 포함)."코드 확인 후 더 정한 것 (U1–U4)
구현 전에 main(v4.11.0)의 코드를 확인하고, 921의 결정 1–10에 다음 네 가지를 더합니다. 921과 다른 부분은 이 댓글을 따릅니다.
U1. attach 안내 3곳도 함께 수정
926/930과 같은 방식으로 다음 세 곳도 고칩니다.
stop_session.sh의 TUI 멈춤 안내 (herdr session attach ...)deploy/INSTALL.md의 예시 (herdr session attach ...)deploy/install_mam.sh의 설치 완료 안내 (HERDR_SERVER_NAME=... herdr agent attach ...)스크립트는 행의
attach_command를 출력합니다. 행이 없으면 #71의lib_py/agents/sanitize.pyattach_command로 만듭니다. 문서는herdr --session <server> agent attach <herdr 이름>형식을 보여 줍니다.U2. stop과 orc-onboard도 업데이트 중에는 거부
둘 다 레지스트리에 씁니다. 업데이트 1단계가 롤백되면 그 사이에 쓴 내용이 레지스트리 백업으로 덮여 사라집니다. 예를 들어
--purge-conversation으로 대화 파일을 지운 행이 되살아날 수 있습니다.update-in-progress표식을 씁니다. 표식이live/unknown이면 거부하고, 레지스트리 쓰기를 마칠 때까지 잠금을 쥡니다.U3. 스킬이 띄우지 않은 모니터가 잠금을 쥐고 있으면 거부
데몬은
start_watchdog이 띄운 형태(daemon.find_running의 패턴)로만 찾을 수 있습니다. 다른 경로로 직접 띄운 모니터는 보이지 않습니다..mam/monitor.lock이 잡혀 있는데 스킬이 띄운 데몬을 찾지 못하면, 아무것도 바꾸기 전에 거부합니다. 스킬이 띄우지 않은 모니터가 잠금을 쥐고 있다는 것과 정리 방법을 안내합니다.U4. 백업 위치와 남은 백업 처리
.mam/update-backup/에 둡니다./tmp는 작은 tmpfs일 수 있고,.venv와.agents/복사본은 inode를 많이 씁니다..mam이나 트리를 복사·해시하는 모든 곳에서.mam/update-backup/을 뺍니다.backing-up→installing(설치가 파일을 바꾸기 직전) →installed(설치 끝).비정상 종료로 남은 백업(주인이 없어 표식이
stale이고 넘겨받은 경우)은 다음 업데이트가 처리합니다. 처리 시점은 사전 확인(결정 1)과 모니터 정지(결정 2) 뒤, 새 백업을 만들기 전입니다.backing-up또는 상태 파일 없음installinginstalled되돌리기가 실패하면 더 바꾸지 않고, 남은 백업을 그대로 둔 채 무엇이 실패했는지 알리고 0이 아닌 코드로 끝냅니다.
범위 밖