원문 캡처 · github.com
원문 캡처 · github.com

git-zmarshal

git-zmarshal 저장소

다중 에이전트 코딩 호스트를 위한 Z 스케일 Git 배포 및 Xcode 빌드 조정 도구다.

여러 코딩 에이전트가 서버 한 대나 개발자용 Mac 한 대가 빌드·테스트·배포를 마치기 전에 main에 병합하는 상황에서는 커밋마다 새 파이프라인을 실행하면 이미 쓸모없어진 빌드가 대기열에 쌓인다. pytest, 데이터베이스 백업, 컨테이너 빌드 또는 Xcode 빌드가 같은 CPU와 디스크 입출력을 두고 경쟁하기도 한다. 호출자는 자신의 커밋이 실제 실행 중인 배포에 반영됐는지 알기 어렵다.

이런 작업 일정 문제를 해결하는 데 대규모 오케스트레이션 인프라나 맞춤형 버전 관리 시스템이 필요한 것은 아니다. git-zmarshal은 Z 스케일(1:220, 가장 작은 모형 철도 궤간)과 철도 조차장에서 이름을 따왔다. 앤스로픽의 Scion 같은 초대규모 다중 에이전트 인프라와 대비되는 이름이다. 이 도구는 표준 Git 저장소에서 쓸 수 있는 의존성 없는 소형 Python 구성 요소와 네이티브 git zmarshal 하위 명령을 제공한다.

  • train: 겹치는 병합 요청 $N$개를 $< N$개의 배포 웨이브로 합친다. 웨이브 1이 본선에서 실행되는 동안 조차장에 도착한 커밋은 대기 중인 웨이브 2 하나로 모인다. 웨이브 2는 출발 시점에 최신 리비전을 확인한다.
  • guard: 호스트에 여유 용량이 있을 때만 무거운 작업을 허용한다. /proc에서 pytest, pg_dump, restic, git gc 같은 프로세스를 살핀다. 컨테이너 CI의 cgroup(docker-*.scope, containerd, kubepods, actions_job)은 검사 대상에서 제외하고, CPU 부하·스왑·Linux PSI 압력을 확인하며 프로세스 간 안전한 대기 중 마커를 기록한다.
  • pytest_plugin: pytest가 협조적으로 작업을 양보하게 한다. 새 테스트 실행은 배포 대기 중이면 수집 전에 기다린다. 진행 중인 직렬 테스트 스위트는 픽스처 정리 뒤 정상적으로 멈추며, pytest의 중단 종료 코드(2)를 반환한다. 따라서 일부만 실행한 테스트가 성공한 것처럼 표시되지 않는다.
  • wave: 여러 커밋으로 이뤄진 웨이브를 실패 시 닫히는 방식으로 검증한다. 제출 전에 브랜치·참조 이름을 고정하고, git --no-replace-objects merge-base --is-ancestor를 사용해 병합된 모든 커밋이 배포 리비전의 조상인지 확인한다. 얕은 클론에서 생기는 거짓 음성과 접두사 충돌도 거부한다.
  • xcode: Apple/Xcode 기기 빌드를 조정한다. ios, ipad, watch 요청을 단일 범용 컴파일 레인(TARGETED_DEVICE_FAMILY=1,2)에 모으고, 실제 기기마다 devicectl 설치를 분산한다.
다이어그램 로딩 중...

설치와 빠른 시작

Python 3.10 이상과 Git이 필요하다. 라이브러리에는 실행 시 필요한 Python 의존성이 없다.

bash
python -m pip install .
# 선택 사항인 플러그인과 테스트 모음을 포함하려면:
python -m pip install '.[pytest,test]'

CLI 실행 파일 이름은 git-zmarshal이다. PATH에 설치하면 Git이 이를 자동으로 git zmarshal 하위 명령으로 제공한다.

bash
# 임시 Git 저장소에서 에이전트 5개의 자체 시뮬레이션을 실행한다.
git zmarshal demo

# 시뮬레이션 결과를 구조화된 JSON으로 출력한다.
git zmarshal demo --json

# 브랜치 또는 짧은 참조를 전체 커밋 해시로 고정한다.
git zmarshal resolve main HEAD~1 --repo /path/to/repo

# 여러 커밋이 배포 리비전의 조상인지 확인한다.
git zmarshal verify <deployed-sha> <commit-a> <commit-b> --repo /path/to/repo

# 호스트의 작업 허용 상태(/proc 프로세스, 부하, 스왑, PSI)와 대기 마커를 살핀다.
git zmarshal guard --max-load-per-cpu 1.5 --max-swap-ratio 0.5 --pressure-limit 20

# 공유 잠금 그룹 사이에 서비스를 교차 배치한다.
git zmarshal plan-wave api worker beat --group api=app --group worker=app

모듈과 경계

모듈책임
git_zmarshal.train순수한 병합 정책과 요청·감사 메타데이터 업데이트
git_zmarshal.guard/proc 프로세스 검사, cgroup 제외, 부하·스왑·압력 확인, 원자적 대기 마커
git_zmarshal.pytest_plugin시작 시 협조적 대기와 테스트 정리 뒤 작업 양보
git_zmarshal.wave서비스 순서 지정, 변경 불가능한 커밋 해석, 여러 커밋의 조상 증명
git_zmarshal.xcode범용 Xcode 레인 정규화(ios/ipad/watch), 여러 기기에 설치 분산, 빌드 대기열 웨이브 병합
git_zmarshal.cligit-zmarshal / git zmarshal CLI(demo, verify, resolve, guard, plan-wave)

이전 추출본과의 호환성을 위해 git_deploy_train도 git_zmarshal의 재내보내기 별칭으로 설치된다. 어느 패키지를 가져와도 I/O를 수행하거나 전역 등록을 하지 않는다.

이 패키지는 배포 데몬, 데이터베이스, HTTP 서버, 빌드 시스템 또는 플랫폼별 배포 스크립트를 제공하지 않는다. 영속성, 트랜잭션, 인증, 서비스 잠금, 소스 체크아웃, 프로세스 실행, 복구, 상태 확인, 롤백은 어댑터가 맡는다.

Train 계획과 정책(`git_zmarshal.train`)

python
from git_zmarshal.train import (
 DeployTrainPolicy,
 add_train_request,
 merge_deploy_result,
 plan_deploy_enqueue,
)

policy = DeployTrainPolicy(window_seconds=5, request_limit=200)

# 상위 수준 계획기: 들어온 배포 요청을 중복 제거할지,
# 실행 중인 웨이브에 합칠지, 대기 중인 웨이브에 병합할지,
# 새 웨이브를 추가할지 결정한다.
plan1 = plan_deploy_enqueue(
 [],
 policy=policy,
 service="api",
 actor="agent-one",
 reason="merge B",
 idempotency_key="first-request",
 revision="commit-b",
)
assert plan1.action == "insert" and plan1.train_step == "started"

plan2 = plan_deploy_enqueue(
 [plan1.job],
 policy=policy,
 service="api",
 actor="agent-two",
 reason="merge C",
 idempotency_key="second-request",
 revision="commit-c",
)
assert plan2.action == "join_queued"
assert plan2.job["coalesced_count"] == 2
assert plan2.job["expected_revisions"] == ["commit-b", "commit-c"]

# 작업자의 최종 결과를 병합할 때 서버 소유 train 메타데이터를 보존한다.
final_job = merge_deploy_result(
 plan2.job,
 {"status": "succeeded", "deployed_revision": "commit-c"},
)
assert final_job["coalesced_count"] == 2
assert final_job["deployed_revision"] == "commit-c"

서버·컨테이너 배포에는 plan_xcode_enqueue(...)와 같은 방식으로 동작하는 plan_deploy_enqueue(...)를 사용하거나, 하위 수준의 policy.eligible(...)과 add_train_request(...)를 사용할 수 있다. 요청이 실행 중인 웨이브에 붙으려면 두 리비전이 모두 알려져 있고 서로 같아야 하며, 실행 중인 웨이브가 오래된 상태가 아니어야 한다. 오래된 웨이브의 기본 제한 시간은 stale_running_timeout_seconds 기준 3600초다. 허가된 예약은 이미 출발 중인 상태다. 체크아웃이 바뀌고 있을 수 있으므로 새 요청은 대기 웨이브에 들어간다.

add_train_request는 복사된 레코드를 반환한다. 같은 멱등성 키를 반복해서 넣어도 개수는 늘어나지 않는다. train_requests 감사 기록이 제한에 도달하더라도 coalesced_idempotency_keys와 expected_revisions 항목은 모두 보존한다. 완료 후 재시도를 처리하려면 이 키를 영속 저장소에 색인해야 한다. 대기 중인 소스 리비전은 고정값이 아니라 관찰값이다. 동시 fetch가 순서 없이 도착할 수 있으므로 출발할 때 다시 해석해야 한다. 실행 중인 소스 리비전은 변경되지 않는다.

호스트 작업 허용과 대기 마커(`git_zmarshal.guard`)

python
from pathlib import Path
from git_zmarshal.guard import GuardConfig, HostAdmissionGuard, PendingStore

guard = HostAdmissionGuard(GuardConfig(
 proc_root=Path("/proc"),
 max_load_per_cpu=1.5,
 max_swap_used_ratio=0.5,
 pressure_limit=20,
 include_xcode_programs=True,
))
pending = PendingStore("/tmp/git-zmarshal-pending")
verdict = guard.check()
if not verdict.ok:
 pending.record("api", {"action": "deploy"}, actor="ci", holds=verdict.holds,
 owner_id="wave-42")
# 이 웨이브와 모든 작업 허용 절차가 끝난 뒤:
pending.clear("api", owner_id="wave-42")

검사기는 셸 인수에 포함된 단어가 아니라 실제 실행 파일 이름과 Python 진입점을 식별한다. 테스트 실행(pytest), 백업 도구(pg_dump, pg_dumpall, pg_basebackup, restic), Git 유지 관리(gc, prune, repack, pack-objects, fsck)를 찾는다. include_xcode_programs=True이면 Apple 빌드 도구(xcodebuild, swift-frontend)도 찾는다. Linux에서는 /proc의 cmdline, cgroup, loadavg, meminfo, pressure/*를 읽는다. /proc이 없는 macOS/POSIX 호스트에서는 ps -axo pid=,args=, os.getloadavg(), sysctl vm.swapusage를 자동으로 대체 사용한다. 자원 임계값은 설정하기 전까지 비활성화되며, 읽는 도중 프로세스가 사라져도 검사를 계속한다.

docker-*.scope, containerd, kubepods, actions_job cgroup에 속한 pytest는 테스트 프로세스 검사에서 제외된다. admitted_cgroup_components로 추가 구성 요소 이름을 지정할 수 있다. 호스트 압력 검사는 계속 적용된다. 이 가드는 보안 경계가 아니라 권고 방식의 작업 일정 조정 도구다. 원격 측정 정보가 없거나 읽을 수 없으면 허용하는 방향으로 실패한다.

PendingStore는 서비스마다 마커 하나를 사용한다. PendingFlag는 record(job_id, service=...), read(), clear(job_id)를 제공하는 단일 deploy-pending.json을 사용한다. 두 방식 모두 원자적 교체와 안정적인 보조 파일 잠금(fcntl.flock)을 사용한다. 항상 소유 웨이브 ID로 마커를 지워야 한다. 이전 웨이브가 새 웨이브의 신호를 지우면 안 된다.

협조적 pytest 핸드셰이크(`git_zmarshal.pytest_plugin`)

bash
export GIT_ZMARSHAL_PENDING_DIR=/run/git-zmarshal/pending
python -m pytest -p git_zmarshal.pytest_plugin

새 실행은 테스트 수집 전에 기다린다. 이때 가벼운 대기 프로세스로 다시 실행되므로 /proc에서 활성 pytest 프로세스로 나타나지 않는다. 테스트 모음 실행 중 마커가 생기면 pytest_runtest_makereport 훅이 테스트 정리 뒤 제어된 중단을 요청한다. 미완료 테스트 모음은 성공이 아니라 pytest의 중단 상태(2)로 종료되므로 테스트 실행기가 재시도할 수 있다. 실행 중인 테스트를 강제 종료하지 않는다.

환경 변수의미
GIT_ZMARSHAL_PENDING_DIR마커 디렉터리. 기본값은 /run/git-zmarshal/pending이며 trains/ 하위 디렉터리도 확인한다. 기존 GIT_DEPLOY_TRAIN_PENDING_DIR도 사용한다.
GIT_ZMARSHAL_PENDING_FILE추가로 지정할 수 있는 단일 플래그 파일
GIT_ZMARSHAL_MAX_WAIT시작 대기 제한 시간(초). 기본값 900, 최댓값 3600
GIT_ZMARSHAL_BYPASS=1시작 대기와 테스트 도중 양보를 명시적으로 건너뛴다.

이 패키지는 자동 pytest11 진입점을 등록하지 않는다. 따라서 -p git_zmarshal.pytest_plugin을 지정하지 않는 한 git-zmarshal을 설치해도 관련 없는 테스트 실행에 영향을 주지 않는다.

여러 커밋 웨이브 검증(`git_zmarshal.wave`)

python
from git_zmarshal.wave import plan_wave, resolve_revisions, verify_wave

# 공유 잠금 그룹 사이에 서비스를 교차 배치한다.
assert plan_wave(["api", "worker", "docs"], {"api": "core", "worker": "core"}) == [
 "api", "docs", "worker"
]

# 병합된 웨이브의 모든 커밋이 배포 리비전에 포함됐는지 확인한다.
from git_zmarshal.wave import resolve_revisions, verify_wave

# 움직이는 브랜치가 요청을 바꾸지 못하도록 제출 전에 이름을 고정한다.
expected = resolve_revisions("/srv/repo", ["feature-one", "feature-two"])
# 이 값은 배포·런타임 출처를 제공하는 어댑터에서 얻는다.
deployed_commit = "0123456789abcdef0123456789abcdef01234567"
proof = verify_wave("/srv/repo", expected, deployed_commit, timeout=10)
if not proof.ok:
 for result in proof.results:
 print(result.revision, result.status, result.detail)

예상 리비전은 각각 커밋 객체로 해석돼야 한다(rev-parse --verify --end-of-options <rev>^{commit}). 검증기는 배포 리비전을 한 번 해석한 뒤 예상 커밋마다 git --no-replace-objects merge-base --is-ancestor를 실행한다. 결과는 equal, ancestor, not-ancestor, unknown 중 하나다. equal과 ancestor만 통과한다. 텍스트 접두사가 일치하는 것만으로는 증거가 되지 않는다. 객체가 없거나 모호한 경우, 잘못된 리비전, Git 오류·시간 초과, 얕은 저장소에서의 부정 확인은 unknown을 반환한다.

Xcode와 Apple 기기 빌드 웨이브(`git_zmarshal.xcode`)

python
from git_zmarshal.xcode import (
 merge_xcode_result,
 normalise_git_ref,
 plan_xcode_enqueue,
 xcode_build_lane,
)

# 범용 앱은 iPhone, iPad, 내장 Watch에서 하나의 컴파일 레인을 공유한다.
assert xcode_build_lane("ios") == "universal-app"
assert xcode_build_lane("ipad") == "universal-app"
assert xcode_build_lane("watch") == "universal-app"

plan = plan_xcode_enqueue(
 active_jobs=[
 {
 "id": "job-1",
 "status": "queued",
 "target": "ios",
 "job_type": "install",
 "git_ref": "origin/main",
 "result": {},
 }
 ],
 target="ipad",
 job_type="install",
 git_ref="refs/heads/main",
 actor="agent-2",
 reason="verify iPad layout",
)
assert plan.action == "join_queued"
assert plan.train["device_kinds"] == ["iphone", "ipad"]

개발자용 Mac에서 실제 기기를 대상으로 하는 Xcode 빌드는 한 번에 하나씩 처리해야 하는 병목이다. 범용 구성표를 한 번 컴파일하고(TARGETED_DEVICE_FAMILY=1,2), 요청된 각 기기 종류(iphone, ipad)에 xcrun devicectl device install app을 실행하면 중복 컴파일을 절반으로 줄인다. 동시에 에이전트의 병합 요청 $N$개를 $N$개 미만의 Xcode 빌드로 합친다.

git_zmarshal.xcode의 주요 불변 조건은 다음과 같다.

  • 범용 레인 설정(xcode_build_lane / canonical_xcode_lane): ios, ipad, watch를 하나의 범용 컴파일 레인에 매핑한다. 기본값은 "universal-app"이며 universal_lane, universal_targets로 바꿀 수 있다. 독립 Watch 시뮬레이터 게이트(("watch", "compile-check"))와 별도 앱 구성표는 독립 레인으로 유지한다.
  • 참조 정규화(normalise_git_ref): origin/, refs/heads/, refs/remotes/origin/ 접두사를 없애고 40자리 16진수 커밋 SHA를 소문자로 바꾼다. 이에 따라 None, "main", "origin/main", "refs/heads/main" 같은 동등한 브랜치 표기는 같은 대기 웨이브로 합쳐지지만, 서로 다른 기능 브랜치는 충돌하지 않는다.
  • 선점된 작업의 안전성(can_join_claimed_xcode_job): 실행 중인 claimed 빌드에 들어오는 요청의 필수 기기 종류가 이미 포함돼 있고, 권위 있는 체크아웃 커밋(result.commit 또는 result.train.resolved_commit)이 호출자의 expected_revision 또는 40자리 16진수 git_ref와 일치할 때만 공유한다.
  • 오래된 선점 작업 필터링: plan_xcode_enqueue는 stale_claim_timeout_seconds보다 오래된 claimed 작업을 무시한다. 기본값은 2700초, 즉 45분이다. 따라서 호출자가 버려진 작업자 선점에 합류하지 않는다.
  • 격리(coalesce=False): coalesce=False를 전달하면 대기 작업에 result.train.isolated = True를 기록한다. 해당 요청과 이후 요청은 이를 다른 요청과 합치지 않는다.
  • 서버 소유 결과 병합(merge_xcode_result): 작업자의 상태 업데이트 중에도 서버 측 result.train 메타데이터를 보존하고, 작업자의 commit(resolved_commit), installed_devices, installed_udids를 승격한다.

참조용 SQLite 및 하위 프로세스 실행기(`examples/minimal_yard.py`)

sqlite3의 BEGIN IMMEDIATE 트랜잭션과 subprocess.run을 사용해 DeployTrainPolicy, HostAdmissionGuard, PendingStore, verify_wave를 함께 묶는 단일 호스트용 배포 실행기는 examples/minimal_yard.py에서 확인할 수 있다.

bash
python examples/minimal_yard.py --demo

이 예제는 진행 중이거나 완료된 웨이브 사이에서 원자적으로 예약하고 요청을 병합한다. /proc 프로세스로 인해 작업이 보류되면 소유자 태그가 붙은 대기 마커를 기록한다. 출발 시 HEAD를 해석하고 하위 프로세스 I/O를 시작하기 전에 SQLite 잠금을 해제하며, 병합된 커밋이 모두 배포 리비전에 포함됐는지 확인한다.

테스트와 라이선스

이 디렉터리에서 이식 가능한 테스트 모음을 실행한다.

bash
python -m pytest -v

테스트는 임시 Git 저장소와 모의 /proc 트리를 만들며 외부 서비스를 요구하지 않는다.

MIT 라이선스다. LICENSE를 참고한다.