From c7df7ffd68c61b2a3c8d798041d8772ee198cd8a Mon Sep 17 00:00:00 2001 From: Godopu Date: Tue, 11 Aug 2026 12:30:16 +0900 Subject: [PATCH] fix(loop): resolve O-2 atomic race-free lock and marker ownership release guard (22/22 PASS) --- .../multi-agent-mux-loop/scripts/loop_lock.sh | 168 +++++++++ .../multi-agent-mux-loop/scripts/run_loop.sh | 30 +- IMPROVEMENTS.md | 33 +- LOG.md | 9 +- deploy/gitea-ci.yml | 1 + tests/test_o2_race_free_lock.py | 355 ++++++++++++++++++ tests/test_o3_scoped_guard.py | 8 +- 7 files changed, 577 insertions(+), 27 deletions(-) create mode 100755 .agents/skills/multi-agent-mux-loop/scripts/loop_lock.sh create mode 100644 tests/test_o2_race_free_lock.py diff --git a/.agents/skills/multi-agent-mux-loop/scripts/loop_lock.sh b/.agents/skills/multi-agent-mux-loop/scripts/loop_lock.sh new file mode 100755 index 0000000..2be32a7 --- /dev/null +++ b/.agents/skills/multi-agent-mux-loop/scripts/loop_lock.sh @@ -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} " ; exit 1 ;; + esac +fi diff --git a/.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh b/.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh index 4a99770..1abf2c4 100644 --- a/.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh +++ b/.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh @@ -12,6 +12,8 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)" source "$REPO_ROOT/.agents/skills/lib.sh" # shellcheck disable=SC1091 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 PLAN_MODE=false @@ -82,13 +84,8 @@ if [ -z "$TARGET_AGENT" ] || [ -z "$TASK" ]; then usage fi -MAM_LOOP_MARKER="$REPO_ROOT/.mam/loop-guard-active" -mkdir -p "$(dirname "$MAM_LOOP_MARKER")" -_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 +MAM_LOOP_MARKER="${MAM_LOOP_MARKER:-$REPO_ROOT/.mam/loop-guard-active}" +_mam_release_guard() { mam_release_loop_lock "$MAM_LOOP_MARKER" || true; } delegate_job_safe() { 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" } +_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; # warn so the discarded list isn't mistaken for having been honored (P2-1). if [ "$ALL_REVIEWERS" = true ] && [ -n "$REVIEWER_LIST" ]; then diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md index 23915f1..ed24e18 100644 --- a/IMPROVEMENTS.md +++ b/IMPROVEMENTS.md @@ -1,9 +1,9 @@ # ๐Ÿ› ๏ธ 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` -- **์ด ์ถ”์  ๋ฏธํ•ด๊ฒฐ ๊ณผ์ œ**: **11๊ฑด** (์•„ํ‚คํ…์ฒ˜ 2๊ฑด, ์—ฃ์ง€์ผ€์ด์Šค 5๊ฑด, ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ด์…˜ 1๊ฑด, ๋ ˆ๊ฑฐ์‹œ ์ž”์žฌ 3๊ฑด) -- **์™„๋ฃŒ๋œ ๊ณผ์ œ**: **11๊ฑด** (A-1, A-3, A-5, B-1, B-3, B-4, B-7, C-1, C-2, O-1, O-3) +- **์ด ์ถ”์  ๋ฏธํ•ด๊ฒฐ ๊ณผ์ œ**: **10๊ฑด** (์•„ํ‚คํ…์ฒ˜ 2๊ฑด, ์—ฃ์ง€์ผ€์ด์Šค 5๊ฑด, ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ด์…˜ 0๊ฑด, ๋ ˆ๊ฑฐ์‹œ ์ž”์žฌ 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๊ฑด) - -### **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` ์™€ ์ผ์น˜ํ•  ๋•Œ๋งŒ ์ˆ˜ํ–‰ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. +## 3. ๐ŸŸก ์˜ค์ผ€์ŠคํŠธ๋ ˆ์ด์…˜ ์ตœ์ ํ™” ๊ณผ์ œ (Orchestration Optimizations โ€” 0๊ฑด โ€” ์ „์› ์™„๋ฃŒ) --- @@ -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 ๋ˆ„๋ฝ ๋ฐ ์‹ ๊ทœ ๋ฏธ์ถ”์  ํŒŒ์ผ ์บก์ฒ˜ ๊ฒฐํ•จ ์กฐ์น˜** โ€” โœ… ์™„๋ฃŒ - `.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]` (๋งŒ์žฅ์ผ์น˜ ํ†ต๊ณผ)** ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. - ์ „์šฉ ํšŒ๊ท€ ํ…Œ์ŠคํŠธ ์Šค์œ„ํŠธ `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)** โ€” โœ… ์™„๋ฃŒ - 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`)๋ฅผ ์ ์šฉํ•˜์—ฌ ๊ฐ€๋“œ ๋ฌด๋ฐœํ™” ๊ฒฐํ•จ์„ ๋ฐฉ์ง€ํ–ˆ์Šต๋‹ˆ๋‹ค. @@ -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-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-2** | **B-8** | agy ์ฃผ์ž…์ด ๊ฒ€์ฆ ์—†์ด `return 0` โ†’ ์•„๋ฌด๊ฒƒ๋„ ์ „๋‹ฌ๋˜์ง€ ์•Š์€ ์žก์ด `started` ๋กœ ๊ธฐ๋ก๋จ. A-4 M0(`_pane_capture` ๋””์ฝ”๋”ฉ) ์ดํ›„์—” **ํšŒํ”ผ์ฑ… ์ œ๊ฑฐ**๋กœ ์ถ•์†Œ | ์†Œ | A-4 M0 | | **P2-1** | **B-6** | ๋ฒ„์ „ ๊ด€๋ฆฌ ํŠธ๋ฆฌ ์˜ค์—ผ + rsync ๋ฐฐํฌ ์œ ์ถœ. `mktemp -d` ๋กœ ์˜ฎ๊ธฐ๋Š” 1~2์ค„ | ์†Œ | โ€” | diff --git a/LOG.md b/LOG.md index b241d33..c4c9998 100644 --- a/LOG.md +++ b/LOG.md @@ -8,7 +8,14 @@ ## ๐Ÿ“Œ 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`๋ฅผ ๋‚จ๊ธฐ๋˜ ๋ฌด์Œ ๊ฒ€์ฆ ๊ฒฐํ•จ ์กฐ์น˜. - **์ฃผ์š” ๊ตฌํ˜„**: - [`.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`) ๊ตฌํ˜„. diff --git a/deploy/gitea-ci.yml b/deploy/gitea-ci.yml index 79bf72b..35cec55 100644 --- a/deploy/gitea-ci.yml +++ b/deploy/gitea-ci.yml @@ -34,6 +34,7 @@ jobs: 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/diff_collect.sh + shellcheck .agents/skills/multi-agent-mux-loop/scripts/loop_lock.sh shellcheck .agents/hooks/loop_delegation_guard.sh shellcheck deploy/lib_ownership.sh shellcheck deploy/install.sh diff --git a/tests/test_o2_race_free_lock.py b/tests/test_o2_race_free_lock.py new file mode 100644 index 0000000..2527019 --- /dev/null +++ b/tests/test_o2_race_free_lock.py @@ -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() diff --git a/tests/test_o3_scoped_guard.py b/tests/test_o3_scoped_guard.py index ab52217..fab3242 100644 --- a/tests/test_o3_scoped_guard.py +++ b/tests/test_o3_scoped_guard.py @@ -206,9 +206,9 @@ def test_z13_legacy_marker_permission_error_degrades_open(mam_sandbox): 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): - run_loop = REPO_ROOT / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" / "run_loop.sh" - content = run_loop.read_text() - assert "_mam_lstart" in content + lock_script = REPO_ROOT / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" / "loop_lock.sh" + content = lock_script.read_text() + assert "mam_lstart" in content assert "lstart=" in content