[Feature] multi-agent-mux-update 스킬 — 업데이트를 스킬로 하고, 끝난 뒤 새 코드로 레지스트리를 마이그레이션한다 (#71 에서 분리) #72

Open
opened 2026-10-25 14:21:10 +00:00 by Godopu · 5 comments
Owner

배경

#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: 정해진 절차는 문서보다 코드(진입점)로
## 배경 #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: 정해진 절차는 문서보다 코드(진입점)로
Author
Owner

#71 작업 결과를 반영한 보충

#71 구현(PR #73)에서 정해진 내용과, 그 과정에서 정한 전제를 정리합니다. 본문과 다른 곳은 이 댓글을 따릅니다.

1. 마이그레이션 진입점 (#71 이 제공)

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 모두 없음) 실패로 보지 않습니다. "바꿀 것이 없음" 으로 출력하고 진입점을 건너뜁니다.
## #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 모두 없음) 실패로 보지 않습니다. "바꿀 것이 없음" 으로 출력하고 진입점을 건너뜁니다.
Author
Owner

구현 전에 정한 것 (결정 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 이 업데이트 전과 같다."
## 구현 전에 정한 것 (결정 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 이 업데이트 전과 같다."
Author
Owner

함께 처리할 것: 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 와 같다."
## 함께 처리할 것: 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` 와 같다."
Author
Owner

함께 처리할 것 (추가): 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자 초과·대문자 이름 포함)."
## 함께 처리할 것 (추가): 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자 초과·대문자 이름 포함)."
Author
Owner

코드 확인 후 더 정한 것 (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에서 다룹니다.
## 코드 확인 후 더 정한 것 (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에서 다룹니다.
Sign in to join this conversation.
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: tmpl/multi-agent-mux#72