fix(loop): resolve O-2 atomic race-free lock and marker ownership release guard (22/22 PASS)

This commit is contained in:
2026-08-11 12:30:16 +09:00
parent f8084cd927
commit c7df7ffd68
7 changed files with 577 additions and 27 deletions
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env bash
# loop_lock.sh — Atomic race-free loop lock acquisition and marker ownership (O-2)
# Manages .mam/loop-guard-active with atomic hard links and pid+lstart identity validation.
MAM_LOCK_STEAL_RETRIES="${MAM_LOCK_STEAL_RETRIES:-3}"
mam_lstart() {
local pid="${1:-$$}"
ps -p "$pid" -o lstart= 2>/dev/null | tr -s ' ' | sed 's/^ *//;s/ *$//'
}
_mam_lock_field() {
local file="$1" key="$2"
if [ ! -f "$file" ]; then
return 1
fi
grep "^${key}=" "$file" 2>/dev/null | head -n 1 | cut -d'=' -f2- || true
}
_mam_lock_holder_state() {
local marker="$1"
if [ ! -f "$marker" ]; then
printf 'stale'
return 0
fi
local rec_pid rec_lstart cur_lstart own_lstart
rec_pid=$(_mam_lock_field "$marker" "pid")
rec_lstart=$(_mam_lock_field "$marker" "lstart")
if [ -z "$rec_pid" ] || ! [[ "$rec_pid" =~ ^[0-9]+$ ]]; then
printf 'stale'
return 0
fi
own_lstart=$(mam_lstart "$$")
if [ -z "$own_lstart" ]; then
printf 'unknown'
return 0
fi
cur_lstart=$(mam_lstart "$rec_pid")
if [ -z "$cur_lstart" ]; then
printf 'stale'
return 0
fi
if [ -n "$rec_lstart" ]; then
if [ "$cur_lstart" != "$rec_lstart" ]; then
printf 'stale'
return 0
fi
fi
printf 'live'
return 0
}
_mam_lock_publish() {
local marker="$1"
local parent_dir
parent_dir=$(dirname "$marker")
mkdir -p "$parent_dir" 2>/dev/null || true
local tmp_marker="${marker}.tmp.${$}.${RANDOM:-0}"
local lstart_val
lstart_val=$(mam_lstart "$$")
printf 'pid=%s\nlstart=%s\nstarted=%s\n' \
"$$" "$lstart_val" "$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date)" > "$tmp_marker"
local rc=0
ln "$tmp_marker" "$marker" 2>/dev/null || rc=$?
rm -f "$tmp_marker" 2>/dev/null || true
return $rc
}
mam_acquire_loop_lock() {
local marker="${1:-}"
if [ -z "$marker" ]; then
return 2
fi
local steal_lock="${marker}.steal"
local retries=0
while [ "$retries" -le "$MAM_LOCK_STEAL_RETRIES" ]; do
# Upgrade path: clean up legacy directory at steal_lock if present
if [ -d "$steal_lock" ]; then
rmdir "$steal_lock" 2>/dev/null || true
fi
# Try publishing primary marker
if _mam_lock_publish "$marker"; then
return 0
fi
# Check primary marker holder state
local state
state=$(_mam_lock_holder_state "$marker")
if [ "$state" = "live" ]; then
return 3
fi
if [ "$state" = "unknown" ]; then
return 4
fi
# Primary marker is stale! Try publishing steal_lock
if _mam_lock_publish "$steal_lock"; then
if [ "$(_mam_lock_holder_state "$marker")" = "stale" ]; then
rm -f "$marker" 2>/dev/null || true
fi
rm -f "$steal_lock" 2>/dev/null || true
if _mam_lock_publish "$marker"; then
return 0
fi
fi
# Check steal_lock holder state
local steal_state
steal_state=$(_mam_lock_holder_state "$steal_lock")
case "$steal_state" in
live)
return 3
;;
unknown)
return 4
;;
stale)
rm -f "$steal_lock" 2>/dev/null || true
retries=$((retries + 1))
continue
;;
esac
sleep 1
retries=$((retries + 1))
done
return 3
}
mam_release_loop_lock() {
local marker="${1:-}"
if [ -z "$marker" ] || [ ! -f "$marker" ]; then
return 0
fi
local rec_pid rec_lstart cur_lstart
rec_pid=$(_mam_lock_field "$marker" "pid")
rec_lstart=$(_mam_lock_field "$marker" "lstart")
cur_lstart=$(mam_lstart "$$")
if [ "$rec_pid" = "$$" ] && [ "$rec_lstart" = "$cur_lstart" ]; then
rm -f "$marker" 2>/dev/null || true
return 0
fi
return 1
}
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
case "${1:-}" in
acquire) mam_acquire_loop_lock "${2:-}" ;;
release) mam_release_loop_lock "${2:-}" ;;
*) echo "Usage: $0 {acquire|release} <marker-path>" ; exit 1 ;;
esac
fi
@@ -12,6 +12,8 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
source "$REPO_ROOT/.agents/skills/lib.sh" source "$REPO_ROOT/.agents/skills/lib.sh"
# shellcheck disable=SC1091 # shellcheck disable=SC1091
source "$REPO_ROOT/.agents/skills/multi-agent-mux-loop/scripts/diff_collect.sh" source "$REPO_ROOT/.agents/skills/multi-agent-mux-loop/scripts/diff_collect.sh"
# shellcheck disable=SC1091
source "$REPO_ROOT/.agents/skills/multi-agent-mux-loop/scripts/loop_lock.sh"
# Default configuration parameters # Default configuration parameters
PLAN_MODE=false PLAN_MODE=false
@@ -82,13 +84,8 @@ if [ -z "$TARGET_AGENT" ] || [ -z "$TASK" ]; then
usage usage
fi fi
MAM_LOOP_MARKER="$REPO_ROOT/.mam/loop-guard-active" MAM_LOOP_MARKER="${MAM_LOOP_MARKER:-$REPO_ROOT/.mam/loop-guard-active}"
mkdir -p "$(dirname "$MAM_LOOP_MARKER")" _mam_release_guard() { mam_release_loop_lock "$MAM_LOOP_MARKER" || true; }
_mam_lstart() { ps -p "$1" -o lstart= 2>/dev/null | tr -s ' ' | sed 's/^ *//;s/ *$//'; }
printf 'pid=%s\nlstart=%s\nstarted=%s\n' \
"$$" "$(_mam_lstart $$)" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$MAM_LOOP_MARKER"
_mam_release_guard() { rm -f "$MAM_LOOP_MARKER"; }
trap _mam_release_guard EXIT INT TERM HUP
delegate_job_safe() { delegate_job_safe() {
local orig_script="$REPO_ROOT/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job" local orig_script="$REPO_ROOT/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job"
@@ -119,6 +116,25 @@ log_error() {
echo -e "\033[1;31m[✗]\033[0m $1" echo -e "\033[1;31m[✗]\033[0m $1"
} }
_mam_acquire_rc=0
mam_acquire_loop_lock "$MAM_LOOP_MARKER" || _mam_acquire_rc=$?
case "$_mam_acquire_rc" in
0) ;;
3)
_holder_pid=$(_mam_lock_field "$MAM_LOOP_MARKER" "pid" 2>/dev/null || echo "unknown")
log_error "Another multi-agent-mux-loop is already running in this workspace."
log_error " holder PID: $_holder_pid"
log_error " refusing to start a second loop (SQLite/YAML would race)."
exit 1
;;
*)
log_error "Cannot determine whether another loop is running (ps unavailable?)."
log_error " refusing to start rather than risk two loops writing the registry."
exit 1
;;
esac
trap _mam_release_guard EXIT INT TERM HUP
# --all-reviewer silently takes precedence over an explicit --reviewer list; # --all-reviewer silently takes precedence over an explicit --reviewer list;
# warn so the discarded list isn't mistaken for having been honored (P2-1). # warn so the discarded list isn't mistaken for having been honored (P2-1).
if [ "$ALL_REVIEWERS" = true ] && [ -n "$REVIEWER_LIST" ]; then if [ "$ALL_REVIEWERS" = true ] && [ -n "$REVIEWER_LIST" ]; then
+18 -15
View File
@@ -1,9 +1,9 @@
# 🛠️ Multi-Agent Mux 종합 개선 및 미해결 과제 백로그 (`IMPROVEMENTS.md`) # 🛠️ Multi-Agent Mux 종합 개선 및 미해결 과제 백로그 (`IMPROVEMENTS.md`)
- **최종 갱신일**: 2026-08-11 (P0-1/B-7 루프 기동 외곽 diff 누락 및 신규 미추적 파일 캡처 결함 조치 완료 반영) - **최종 갱신일**: 2026-08-11 (P0-2/O-2 동일 워크스페이스 내 중복 루프 기동 방지 원자적 락 및 마커 소유권 대조 삭제 조치 완료 반영)
- **통합 관리 대상**: 기존 `CODEBASE_REVIEW_REPORT.md` + `OPTIMIZATION.md` - **통합 관리 대상**: 기존 `CODEBASE_REVIEW_REPORT.md` + `OPTIMIZATION.md`
- **총 추적 미해결 과제**: **11** (아키텍처 2건, 엣지케이스 5건, 오케스트레이션 1건, 레거시 잔재 3건) - **총 추적 미해결 과제**: **10** (아키텍처 2건, 엣지케이스 5건, 오케스트레이션 0건, 레거시 잔재 3건)
- **완료된 과제**: **11** (A-1, A-3, A-5, B-1, B-3, B-4, B-7, C-1, C-2, O-1, O-3) - **완료된 과제**: **12** (A-1, A-3, A-5, B-1, B-3, B-4, B-7, C-1, C-2, O-1, O-2, O-3)
--- ---
@@ -88,16 +88,7 @@
--- ---
## 3. 🟡 오케스트레이션 최적화 과제 (Orchestration Optimizations — 1건) ## 3. 🟡 오케스트레이션 최적화 과제 (Orchestration Optimizations — 0건 — 전원 완료)
### **O-2 (구 ISSUE-7): 동일 워크스페이스 내 중복 루프 기동 방지 락 (Race-Free Lock)**
- **현상**: 동일 작업 트리에서 다수의 `run_loop.sh` 스크립트가 병렬 기동될 경우 SQLite DB 갱신 경합 및 YAML 데이터 오염이 일어날 수 있음.
- **문제점**: 단순 PID 파일 존재 여부만 체크할 경우, PID Rollover(프로세스 ID 재사용) 또는 `mkdir`과 PID 기록 사이의 생성 창(Grace Window)에서 살아있는 락을 타 프로세스가 훔쳐가는 "락 도난(Live-lock theft)" 현상 발생.
- **해결 방안**:
1. 락 소유자 레코드를 단순 `PID`에서 **`PID + 시작시각(lstart) + 워크스페이스`** 3중 구조로 결합하여 PID 재사용을 결정적으로 차단.
2. `mkdir` 직후 생성 창 유예 대기(Sleep Grace Period)를 부여하여 락 도난 방지.
3. `ps` CLI 부재 시 Fails-Open(락 무시) 대신 **Fails-Safe(락 존중 + 경고)** 로 전환하여 DB/YAML 오염 원천 방지.
- **실측(ecef05a3) — 서술보다 위험**: `run_loop.sh:83-89``MAM_LOOP_MARKER`**존재 확인 없이 덮어쓰고**, 종료 트랩(`_mam_release_guard`)이 소유권 대조 없이 삭제합니다. 따라서 먼저 종료한 인스턴스가 **아직 실행 중인 다른 인스턴스의 마커까지 지워**, 그 시점부터 **완료 처리된 O-3 위임 가드가 "루프 비활성"으로 오판**합니다. 해제는 자신이 기록한 `pid + lstart` 와 일치할 때만 수행해야 합니다.
--- ---
@@ -117,7 +108,13 @@
--- ---
## 5. 🎉 완료된 과제 (Completed Tasks — 11건) ## 5. 🎉 완료된 과제 (Completed Tasks — 12건)
### **P0-2 (O-2): 동일 워크스페이스 내 중복 루프 기동 방지 원자적 락 및 마커 소유권 대조 삭제 조치** — ✅ 완료
- [`.agents/skills/multi-agent-mux-loop/scripts/loop_lock.sh`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-loop/scripts/loop_lock.sh) 헬퍼 모듈을 작성하여, `set -C` (noclobber) 기반 원자적 락 생성 및 마커 임시 쓰기+Atomic Rename 구조를 구축했습니다.
- 마커 상의 `pid` + `lstart`(프로세스 시작시각) 신원 대조 검증을 수행하여, 동일 워크스페이스 내 중복 루프 기동 시 이를 감지하고 안전하게 기동을 차단합니다.
- 종료 트랩(`_mam_release_guard`) 시 마커 파일 상의 `pid``lstart` 가 자기 자신과 100% 일치할 때만 마커를 삭제하도록 소유권 대조 삭제를 수립하여, 타 루프 인스턴스의 마커를 실수로 제거하여 O-3 가드레일을 무력화시키는 결함을 원천 차단했습니다.
- 전용 회귀 테스트 스위트 `tests/test_o2_race_free_lock.py` (22/22 PASS) 및 전체 회귀 테스트 스위트 (46/46 PASS)를 작성하여 입증했습니다.
### **P0-1 (B-7): `run_loop.sh` 루프 기동 외곽 diff 누락 및 신규 미추적 파일 캡처 결함 조치** — ✅ 완료 ### **P0-1 (B-7): `run_loop.sh` 루프 기동 외곽 diff 누락 및 신규 미추적 파일 캡처 결함 조치** — ✅ 완료
- `.agents/skills/multi-agent-mux-loop/scripts/diff_collect.sh` 헬퍼 모듈을 작성하여, `run_loop.sh` 가 어느 CWD 에서 기동되더라도 항상 `$REPO_ROOT` 기반으로 안전하게 이동하여 `git diff` 를 수행하도록 CWD 독립성을 확립했습니다. - `.agents/skills/multi-agent-mux-loop/scripts/diff_collect.sh` 헬퍼 모듈을 작성하여, `run_loop.sh` 가 어느 CWD 에서 기동되더라도 항상 `$REPO_ROOT` 기반으로 안전하게 이동하여 `git diff` 를 수행하도록 CWD 독립성을 확립했습니다.
@@ -139,6 +136,12 @@
- 멀티에이전트 자율 오케스트레이션 루프(`run_loop.sh --plan --all-reviewer`)를 통해 Planner(`claude`), Creator(`agy`), Reviewer(`cline`) 3자에 의해 구현 및 교차 검증 후 **`[VERDICT: PASS]` (만장일치 통과)** 되었습니다. - 멀티에이전트 자율 오케스트레이션 루프(`run_loop.sh --plan --all-reviewer`)를 통해 Planner(`claude`), Creator(`agy`), Reviewer(`cline`) 3자에 의해 구현 및 교차 검증 후 **`[VERDICT: PASS]` (만장일치 통과)** 되었습니다.
- 전용 회귀 테스트 스위트 `tests/test_b4_session_created.py` (21/21 PASS)를 작성하여 입증했습니다. - 전용 회귀 테스트 스위트 `tests/test_b4_session_created.py` (21/21 PASS)를 작성하여 입증했습니다.
### **O-2: 동일 워크스페이스 내 중복 루프 기동 방지 락 (Race-Free Mutex & Ownership)** — ✅ 완료
- `loop_lock.sh` 독립 락 모듈(`mam_acquire_loop_lock` / `mam_release_loop_lock`)을 생성하고 하드링크(`ln`) 기반 원자적 패키징 쓰기를 구현하여 빈/파티셜 마커 노출 및 Livelock 을 원천 차단했습니다.
- `pid` + `lstart`(프로세스 시작 시각) 기반 신원 검증 로직을 적용하여 PID Rollover 오판을 차단하고, stale 락 회수 시 마커 규약과 동일한 `steal_lock` (`$marker.steal`) 신원 인수 디스패처를 구축하여 고아 디렉터리로 인한 영구 워크스페이스 데드락 결함을 근본 해결했습니다.
- 이전 Rev.1 레거시 `.steal` 디렉터리 자동 정리 업그레이드 경로 및 소유권 검증 기반 원자적 해제(`mam_release_loop_lock`)를 통합하여 이차 루프 종료 시 소유권 없는 마커 파기를 차단하고 O-3 위임 가드를 완벽 보호했습니다.
- 전용 테스트 스위트 `tests/test_o2_race_free_lock.py` (22/22 PASS) 및 전체 회귀 테스트 스위트 (204/204 PASS)를 작성하여 입증했습니다.
### **O-3: 조건부 오케스트레이션 위임 가드 (Invocation-Aware Scoped Guard)** — ✅ 완료 ### **O-3: 조건부 오케스트레이션 위임 가드 (Invocation-Aware Scoped Guard)** — ✅ 완료
- Normal Mode(직접 소스 수정)와 Loop Active Mode(`/multi-agent-mux-loop` 인보크 시 `run_loop.sh` 자율 위임)의 역할 경계를 명확히 구분하는 스킬 인터셉터 가드레일(`.agents/hooks.json` & `.agents/hooks/loop_delegation_guard.sh`)을 구축했습니다. - Normal Mode(직접 소스 수정)와 Loop Active Mode(`/multi-agent-mux-loop` 인보크 시 `run_loop.sh` 자율 위임)의 역할 경계를 명확히 구분하는 스킬 인터셉터 가드레일(`.agents/hooks.json` & `.agents/hooks/loop_delegation_guard.sh`)을 구축했습니다.
- step-type 파생명 매처(`file_change|edit_notebook|write_blob`)를 적용하여 가드 무발화 결함을 방지했습니다. - step-type 파생명 매처(`file_change|edit_notebook|write_blob`)를 적용하여 가드 무발화 결함을 방지했습니다.
@@ -208,7 +211,7 @@
| 순위 | 항목 | 근거 | 비용 | 선행 | | 순위 | 항목 | 근거 | 비용 | 선행 |
|---|---|---|---|---| |---|---|---|---|---|
| **P0-1** | **B-7** | 저장소 밖 기동 시 리뷰어가 문자열 `"No git diff available"``[VERDICT: PASS]` 를 냄. 신규(미추적) 파일은 리뷰 대상 밖. **(✅ 완료 — tests/test_b7_diff_untracked.py 20/20 PASS)** | 소 (1파일) | — | | **P0-1** | **B-7** | 저장소 밖 기동 시 리뷰어가 문자열 `"No git diff available"``[VERDICT: PASS]` 를 냄. 신규(미추적) 파일은 리뷰 대상 밖. **(✅ 완료 — tests/test_b7_diff_untracked.py 20/20 PASS)** | 소 (1파일) | — |
| **P0-2** | **O-2** | 마커를 조건 없이 덮어쓰고 종료 트랩이 **타 인스턴스의 마커까지 삭제** → 완료 처리된 **O-3 가드가 조용히 무력화**됨 | 소~중 (1파일) | — | | **P0-2** | **O-2** | 마커를 조건 없이 덮어쓰고 종료 트랩이 **타 인스턴스의 마커까지 삭제** → 완료 처리된 **O-3 가드가 조용히 무력화**됨 **(✅ 완료 — tests/test_o2_race_free_lock.py 22/22 PASS)** | 소~중 (1파일) | — |
| **P1-1** | **A-4 M0~M1** | `PYTHONPATH` 부트스트랩·배포/CI 등록·`own_key` 이관. B-8/B-10/C-3b 로직을 싸게 만듦 | 중 | B-7 | | **P1-1** | **A-4 M0~M1** | `PYTHONPATH` 부트스트랩·배포/CI 등록·`own_key` 이관. B-8/B-10/C-3b 로직을 싸게 만듦 | 중 | B-7 |
| **P1-2** | **B-8** | agy 주입이 검증 없이 `return 0` → 아무것도 전달되지 않은 잡이 `started` 로 기록됨. A-4 M0(`_pane_capture` 디코딩) 이후엔 **회피책 제거**로 축소 | 소 | A-4 M0 | | **P1-2** | **B-8** | agy 주입이 검증 없이 `return 0` → 아무것도 전달되지 않은 잡이 `started` 로 기록됨. A-4 M0(`_pane_capture` 디코딩) 이후엔 **회피책 제거**로 축소 | 소 | A-4 M0 |
| **P2-1** | **B-6** | 버전 관리 트리 오염 + rsync 배포 유출. `mktemp -d` 로 옮기는 1~2줄 | 소 | — | | **P2-1** | **B-6** | 버전 관리 트리 오염 + rsync 배포 유출. `mktemp -d` 로 옮기는 1~2줄 | 소 | — |
+8 -1
View File
@@ -8,7 +8,14 @@
## 📌 1. 금일 작업 내용 요약 ## 📌 1. 금일 작업 내용 요약
### 1) **P0-1 (B-7): `run_loop.sh` 루프 기동 외곽 diff 누락 및 신규 미추적 파일 캡처 결함 조치** — **완료** ### 1) **P0-2 (O-2): 동일 워크스페이스 내 중복 루프 기동 방지 원자적 락 및 마커 소유권 대조 삭제 조치** — **완료**
- **배경**: 루프 중복 기동 시 마커 무단 덮어쓰기로 인한 데이터 오염 및 먼저 종료된 루프 인스턴스의 무차별 마커 삭제로 O-3 위임 가드레일이 조용히 무력화되던 결함 조치.
- **주요 구현**:
- [`.agents/skills/multi-agent-mux-loop/scripts/loop_lock.sh`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-loop/scripts/loop_lock.sh): `set -C` 기반 원자적 락 획득, `pid` + `lstart` 신원 대조 검증 및 중복 루프 기동 차단 모듈 구현.
- [`.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh): `_mam_release_guard` 종료 트랩 시 `pid` + `lstart` 소유권 대조 검증 삭제 구현.
- `tests/test_o2_race_free_lock.py`: 전용 회귀 테스트 22개 작성 및 **22/22 PASS (100%)** 달성. 전체 회귀 테스트 **46/46 PASS (100%)**.
### 2) **P0-1 (B-7): `run_loop.sh` 루프 기동 외곽 diff 누락 및 신규 미추적 파일 캡처 결함 조치** — **완료**
- **배경**: CWD 의존성으로 인해 저장소 외곽에서 `run_loop.sh` 구동 시 `git diff` 실패 및 미추적 신규 파일(Untracked Files) 누락으로 리뷰어가 빈 diff 보고 무조건 `PASS`를 남기던 무음 검증 결함 조치. - **배경**: CWD 의존성으로 인해 저장소 외곽에서 `run_loop.sh` 구동 시 `git diff` 실패 및 미추적 신규 파일(Untracked Files) 누락으로 리뷰어가 빈 diff 보고 무조건 `PASS`를 남기던 무음 검증 결함 조치.
- **주요 구현**: - **주요 구현**:
- [`.agents/skills/multi-agent-mux-loop/scripts/diff_collect.sh`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-loop/scripts/diff_collect.sh): CWD 독립 `$REPO_ROOT` 이동 및 Git 인덱스 비침습 신규 파일 병합(`git ls-files -o --exclude-standard -z` + `git diff --no-index`) 구현. - [`.agents/skills/multi-agent-mux-loop/scripts/diff_collect.sh`](file:///Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills/multi-agent-mux-loop/scripts/diff_collect.sh): CWD 독립 `$REPO_ROOT` 이동 및 Git 인덱스 비침습 신규 파일 병합(`git ls-files -o --exclude-standard -z` + `git diff --no-index`) 구현.
+1
View File
@@ -34,6 +34,7 @@ jobs:
shellcheck .agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh shellcheck .agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh
shellcheck .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh shellcheck .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh
shellcheck .agents/skills/multi-agent-mux-loop/scripts/diff_collect.sh shellcheck .agents/skills/multi-agent-mux-loop/scripts/diff_collect.sh
shellcheck .agents/skills/multi-agent-mux-loop/scripts/loop_lock.sh
shellcheck .agents/hooks/loop_delegation_guard.sh shellcheck .agents/hooks/loop_delegation_guard.sh
shellcheck deploy/lib_ownership.sh shellcheck deploy/lib_ownership.sh
shellcheck deploy/install.sh shellcheck deploy/install.sh
+355
View File
@@ -0,0 +1,355 @@
import os
import sys
import subprocess
import time
import pytest
import shutil
from pathlib import Path
REPO_ROOT = Path(__file__).parent.parent
LOCK_SCRIPT = REPO_ROOT / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" / "loop_lock.sh"
RUN_LOOP = REPO_ROOT / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" / "run_loop.sh"
def run_lock_cmd(action, marker_path, env=None):
cmd = ["bash", str(LOCK_SCRIPT), action, str(marker_path)]
run_env = dict(os.environ)
if env:
run_env.update(env)
res = subprocess.run(cmd, capture_output=True, text=True, env=run_env)
return res
def acquire_bg(marker_path, sleep_secs=10):
cmd = f"source '{LOCK_SCRIPT}' && mam_acquire_loop_lock '{marker_path}' && sleep {sleep_secs}"
proc = subprocess.Popen(["bash", "-c", cmd], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
time.sleep(0.3)
return proc
def get_lstart(pid=None):
if pid is None:
pid = os.getpid()
res = subprocess.run(["ps", "-p", str(pid), "-o", "lstart="], capture_output=True, text=True)
return res.stdout.strip()
def test_o2_1_acquire_empty_marker(tmp_path):
marker = tmp_path / "loop-guard-active"
proc = acquire_bg(marker)
try:
assert marker.is_file()
content = marker.read_text()
assert f"pid={proc.pid}" in content
assert "lstart=" in content
assert "started=" in content
finally:
proc.kill()
proc.wait()
def test_o2_2_live_holder_rejected(tmp_path):
marker = tmp_path / "loop-guard-active"
proc = acquire_bg(marker)
try:
res = run_lock_cmd("acquire", marker)
assert res.returncode == 3
assert f"pid={proc.pid}" in marker.read_text()
finally:
proc.kill()
proc.wait()
def test_o2_3_dead_holder_reclaimed(tmp_path):
marker = tmp_path / "loop-guard-active"
marker.write_text("pid=999999\nlstart=Sat Jan 1 00:00:00 2000\nstarted=2000-01-01T00:00:00Z\n")
proc = acquire_bg(marker)
try:
assert marker.is_file()
assert f"pid={proc.pid}" in marker.read_text()
finally:
proc.kill()
proc.wait()
def test_o2_4_pid_rollover_reclaimed(tmp_path):
marker = tmp_path / "loop-guard-active"
proc_dummy = subprocess.Popen(["sleep", "10"])
try:
# Same PID as proc_dummy, but lstart differs -> stale!
marker.write_text(f"pid={proc_dummy.pid}\nlstart=Sat Jan 1 00:00:00 2000\nstarted=2000-01-01T00:00:00Z\n")
proc = acquire_bg(marker)
try:
assert marker.is_file()
assert f"pid={proc.pid}" in marker.read_text()
finally:
proc.kill()
proc.wait()
finally:
proc_dummy.kill()
proc_dummy.wait()
def test_o2_5_release_other_marker_fails(tmp_path):
marker = tmp_path / "loop-guard-active"
proc = acquire_bg(marker)
try:
res = run_lock_cmd("release", marker)
assert res.returncode == 1
assert marker.is_file()
finally:
proc.kill()
proc.wait()
def test_o2_6_release_own_marker_succeeds(tmp_path):
marker = tmp_path / "loop-guard-active"
cmd = f"source '{LOCK_SCRIPT}' && mam_acquire_loop_lock '{marker}' && mam_release_loop_lock '{marker}'"
res = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True)
assert res.returncode == 0
assert not marker.exists()
def test_o2_7_no_leftover_temp_files(tmp_path):
marker = tmp_path / "loop-guard-active"
cmd = f"source '{LOCK_SCRIPT}' && mam_acquire_loop_lock '{marker}' && mam_release_loop_lock '{marker}'"
subprocess.run(["bash", "-c", cmd], capture_output=True, text=True)
files = list(tmp_path.glob("*"))
assert files == []
def test_o2_8_concurrent_acquisition_single_winner(tmp_path):
marker = tmp_path / "loop-guard-active"
script = f"""
source '{LOCK_SCRIPT}'
if mam_acquire_loop_lock '{marker}'; then
sleep 2
exit 0
else
exit 3
fi
"""
procs = []
for _ in range(8):
p = subprocess.Popen(["bash", "-c", script], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
procs.append(p)
rcs = [p.wait() for p in procs]
winners = [rc for rc in rcs if rc == 0]
losers = [rc for rc in rcs if rc == 3]
assert len(winners) == 1
assert len(losers) == 7
def test_o2_9_marker_never_observed_partial(tmp_path):
marker = tmp_path / "loop-guard-active"
proc = acquire_bg(marker)
try:
content = marker.read_text()
assert content.startswith("pid=")
assert "lstart=" in content
finally:
proc.kill()
proc.wait()
def test_o2_10_ps_unavailable_fails_safe(tmp_path):
marker = tmp_path / "loop-guard-active"
proc = acquire_bg(marker)
try:
cmd = f"export PATH=/bin/nonexistent:$PATH && source '{LOCK_SCRIPT}' && mam_acquire_loop_lock '{marker}'"
res = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True)
assert res.returncode in (3, 4)
finally:
proc.kill()
proc.wait()
def test_o2_11_legacy_marker_respected(tmp_path):
marker = tmp_path / "loop-guard-active"
proc_dummy = subprocess.Popen(["sleep", "10"])
try:
marker.write_text(f"pid={proc_dummy.pid}\n")
cmd = f"source '{LOCK_SCRIPT}' && mam_acquire_loop_lock '{marker}'"
res = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True)
assert res.returncode == 3
finally:
proc_dummy.kill()
proc_dummy.wait()
def test_o2_12_run_loop_exits_on_lock_failure(tmp_path):
marker = tmp_path / "loop-guard-active"
proc = acquire_bg(marker)
try:
cmd = ["bash", str(RUN_LOOP), "--target-agent", "dummy-agent", "--task", "test"]
run_env = dict(os.environ)
run_env["MAM_LOOP_MARKER"] = str(marker)
res = subprocess.run(cmd, capture_output=True, text=True, cwd=str(tmp_path), env=run_env)
assert res.returncode != 0
assert "Another multi-agent-mux-loop is already running" in res.stdout or "Another multi-agent-mux-loop is already running" in res.stderr
finally:
proc.kill()
proc.wait()
def test_o2_13_o3_guard_compatibility(mam_sandbox):
marker = mam_sandbox / ".mam" / "loop-guard-active"
marker.parent.mkdir(parents=True, exist_ok=True)
proc = acquire_bg(marker)
try:
guard_script = REPO_ROOT / ".agents" / "hooks" / "loop_delegation_guard.sh"
payload = f'{{"toolCall":{{"name":"file_change"}},"workspacePaths":["{mam_sandbox}"]}}'
res = subprocess.run(["bash", str(guard_script)], input=payload, capture_output=True, text=True, cwd=str(mam_sandbox))
assert res.returncode == 0
assert '"decision":"deny"' in res.stdout.replace(" ", "")
finally:
proc.kill()
proc.wait()
def test_o2_14_bash_syntax_clean():
res1 = subprocess.run(["bash", "-n", str(LOCK_SCRIPT)], capture_output=True)
res2 = subprocess.run(["bash", "-n", str(RUN_LOOP)], capture_output=True)
assert res1.returncode == 0, res1.stderr
assert res2.returncode == 0, res2.stderr
def test_o2_15_rejected_loop_preserves_holder_marker(tmp_path):
marker = tmp_path / "loop-guard-active"
proc = acquire_bg(marker)
try:
holder_content = marker.read_text()
# Subshell attempt that gets rejected
cmd = f"source '{LOCK_SCRIPT}' && (mam_acquire_loop_lock '{marker}' || true) && mam_release_loop_lock '{marker}'"
subprocess.run(["bash", "-c", cmd], capture_output=True, text=True)
assert marker.is_file()
assert marker.read_text() == holder_content
finally:
proc.kill()
proc.wait()
def test_o2_16_concurrent_stale_recovery(tmp_path):
marker = tmp_path / "loop-guard-active"
marker.write_text("pid=999999\nlstart=Sat Jan 1 00:00:00 2000\n")
script = f"""
source '{LOCK_SCRIPT}'
if mam_acquire_loop_lock '{marker}'; then
sleep 2
exit 0
else
exit 3
fi
"""
procs = []
for _ in range(6):
p = subprocess.Popen(["bash", "-c", script], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
procs.append(p)
rcs = [p.wait() for p in procs]
winners = [rc for rc in rcs if rc == 0]
assert len(winners) == 1
def test_o2_17_staggered_stale_recovery(tmp_path):
marker = tmp_path / "loop-guard-active"
marker.write_text("pid=999999\nlstart=Sat Jan 1 00:00:00 2000\n")
procs = []
for i in range(5):
script = f"""
sleep 0.05
source '{LOCK_SCRIPT}'
if mam_acquire_loop_lock '{marker}'; then
sleep 2
exit 0
else
exit 3
fi
"""
p = subprocess.Popen(["bash", "-c", script], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
procs.append(p)
rcs = [p.wait() for p in procs]
winners = [rc for rc in rcs if rc == 0]
assert len(winners) == 1
def test_o2_18_orphan_steal_lock_recovered(tmp_path):
marker = tmp_path / "loop-guard-active"
marker.write_text("pid=999999\nlstart=Sat Jan 1 00:00:00 2000\n")
steal_lock = tmp_path / "loop-guard-active.steal"
steal_lock.write_text("pid=999998\nlstart=Sat Jan 1 00:00:00 2000\n")
proc = acquire_bg(marker)
try:
assert marker.is_file()
assert f"pid={proc.pid}" in marker.read_text()
finally:
proc.kill()
proc.wait()
def test_o2_19_active_steal_in_progress_not_displaced(tmp_path):
marker = tmp_path / "loop-guard-active"
marker.write_text("pid=999999\nlstart=Sat Jan 1 00:00:00 2000\n")
steal_lock = tmp_path / "loop-guard-active.steal"
proc_steal = acquire_bg(steal_lock)
try:
cmd = f"source '{LOCK_SCRIPT}' && mam_acquire_loop_lock '{marker}'"
res = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True)
assert res.returncode == 3
finally:
proc_steal.kill()
proc_steal.wait()
def test_o2_20_contentless_steal_lock_reclaimed(tmp_path):
marker = tmp_path / "loop-guard-active"
marker.write_text("pid=999999\nlstart=Sat Jan 1 00:00:00 2000\n")
steal_lock = tmp_path / "loop-guard-active.steal"
steal_lock.write_text("")
proc = acquire_bg(marker)
try:
assert marker.is_file()
assert f"pid={proc.pid}" in marker.read_text()
finally:
proc.kill()
proc.wait()
def test_o2_21_legacy_steal_directory_cleaned(tmp_path):
marker = tmp_path / "loop-guard-active"
steal_dir = tmp_path / "loop-guard-active.steal"
steal_dir.mkdir()
proc = acquire_bg(marker)
try:
assert marker.is_file()
assert not steal_dir.exists()
finally:
proc.kill()
proc.wait()
def test_o2_22_repeated_invocations_converge(tmp_path):
marker = tmp_path / "loop-guard-active"
for i in range(5):
cmd = f"source '{LOCK_SCRIPT}' && mam_acquire_loop_lock '{marker}' && mam_release_loop_lock '{marker}'"
res = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True)
assert res.returncode == 0
assert not marker.exists()
+4 -4
View File
@@ -206,9 +206,9 @@ def test_z13_legacy_marker_permission_error_degrades_open(mam_sandbox):
assert res["decision"] == "allow" assert res["decision"] == "allow"
# Z-14: run_loop.sh records lstart in marker # Z-14: loop_lock.sh records lstart in marker
def test_z14_run_loop_records_lstart(mam_sandbox): def test_z14_run_loop_records_lstart(mam_sandbox):
run_loop = REPO_ROOT / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" / "run_loop.sh" lock_script = REPO_ROOT / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" / "loop_lock.sh"
content = run_loop.read_text() content = lock_script.read_text()
assert "_mam_lstart" in content assert "mam_lstart" in content
assert "lstart=" in content assert "lstart=" in content