Files
multi-agent-mux/tests/test_a3_buffer_isolation.py
T

275 lines
8.1 KiB
Python

"""A-3 — shift buffer isolation & garbage collection.
Defects addressed:
① Colliding buffer names (e.g. constant "sks_adhoc" or "sks_onboard") caused
cross-agent prompt contamination and silent buffer deletion loss.
② Call-unique buffer names (sks_<job>_<pid>_<rand>) fixed ① but introduced a
permanent leak of abandoned buffers (SIGINT/SIGTERM/timeout between
set-buffer and delete-buffer). Fixed with automatic GC (-mmin +60).
③ F2's atomic write created DOT-prefixed temp files ('.buf_sks_....tmp') that
F4's initial GC pattern ('buf_sks_*') missed. Fixed by widening the GC
pattern to include '.buf_sks_*' and cleaning up on write/rename failure.
"""
import os
import subprocess
import time
import pytest
BARE_PATH = "/usr/bin:/bin:/usr/sbin:/sbin"
def _bash(sandbox, snippet, extra_env=None):
env = dict(os.environ)
env["PATH"] = BARE_PATH
env["WORKSPACE_ROOT"] = str(sandbox)
if extra_env:
env.update(extra_env)
script = f'source "{sandbox}/.agents/skills/lib.sh" >/dev/null 2>&1\n{snippet}'
return subprocess.run(["bash", "-c", script], capture_output=True,
text=True, cwd=str(sandbox), env=env)
# Harness to invoke send_keys_safe without a running herdr daemon or tmux
def _run_send_keys_safe(sandbox, sess, text, job_id=None):
buflog = sandbox / f"buflog_{time.time_ns()}.txt"
job_arg = f' "{job_id}"' if job_id is not None else ""
snippet = f"""
_pane_quiescent() {{ return 0; }}
_pane_dialog_open() {{ return 1; }}
_pane_capture() {{ printf '%s' "$SKS_FAKE_PANE"; }}
_sks_herdr() {{
case "$1" in
set-buffer|paste-buffer|delete-buffer)
[ "$2" = "-b" ] && printf '%s %s\\n' "$1" "$3" >> "{buflog}" ;;
esac
return 0
}}
export SKS_FAKE_PANE="● {text}"
send_keys_safe "{sess}" "{text}"{job_arg}
"""
res = _bash(sandbox, snippet)
log_lines = buflog.read_text().splitlines() if buflog.exists() else []
return res, log_lines
# X-1 — Two calls with the same job_id (e.g. "onboard") get DIFFERENT buffer names
def test_a3_same_job_id_gets_different_buffer_names(mam_sandbox):
res1, lines1 = _run_send_keys_safe(mam_sandbox, "test_sess1", "hello", "onboard")
res2, lines2 = _run_send_keys_safe(mam_sandbox, "test_sess2", "world", "onboard")
assert res1.returncode == 0 and res2.returncode == 0
buf1 = lines1[0].split()[1]
buf2 = lines2[0].split()[1]
assert buf1 != buf2, f"buffer names collided: {buf1} == {buf2}"
# X-2 — Within one call, set-buffer, paste-buffer, and delete-buffer use the SAME name
def test_a3_triple_uses_same_buffer_name(mam_sandbox):
res, lines = _run_send_keys_safe(mam_sandbox, "test_sess1", "test payload", "job1")
assert res.returncode == 0
assert len(lines) == 3
cmd0, name0 = lines[0].split()
cmd1, name1 = lines[1].split()
cmd2, name2 = lines[2].split()
assert cmd0 == "set-buffer" and cmd1 == "paste-buffer" and cmd2 == "delete-buffer"
assert name0 == name1 == name2
# X-3 — Different buffer names map to different files; delete does not touch another's file
def test_a3_different_names_do_not_interfere(mam_sandbox):
res = _bash(mam_sandbox, """
SHIM="$WORKSPACE_ROOT/.mam/shim/herdr"
DIR="$WORKSPACE_ROOT/.mam/shim"
"$SHIM" set-buffer -b "buf_A" "PAYLOAD_A"
"$SHIM" set-buffer -b "buf_B" "PAYLOAD_B"
assert_file() { [ -f "$DIR/$1" ] && echo "$1:EXISTS" || echo "$1:MISSING"; }
assert_file "buf_buf_A"
assert_file "buf_buf_B"
"$SHIM" delete-buffer -b "buf_A"
assert_file "buf_buf_A"
assert_file "buf_buf_B"
""")
assert "buf_buf_A:EXISTS" in res.stdout
assert "buf_buf_B:EXISTS" in res.stdout
assert "buf_buf_A:MISSING" in res.stdout
assert "buf_buf_B:EXISTS" in res.stdout
# X-4 — Invalid/empty -b names are rejected explicitly, not falling back to tmp_buffer
def test_a3_unusable_buffer_name_is_rejected(mam_sandbox):
res = _bash(mam_sandbox, """
SHIM="$WORKSPACE_ROOT/.mam/shim/herdr"
"$SHIM" set-buffer -b "!!!" "PAYLOAD" 2>&1
echo "RC:$?"
""")
assert "RC:1" in res.stdout
assert "contains no usable characters" in res.stdout + res.stderr
# X-5 — Parallel execution test: 12 callers get isolated buffer text
def test_a3_parallel_calls_are_isolated(mam_sandbox):
res = _bash(mam_sandbox, """
SHIM="$WORKSPACE_ROOT/.mam/shim/herdr"
DIR="$WORKSPACE_ROOT/.mam/shim"
for i in $(seq 1 12); do
"$SHIM" set-buffer -b "par_$i" "DATA_$i" &
done
wait
for i in $(seq 1 12); do
content=$(cat "$DIR/buf_par_$i" 2>/dev/null)
if [ "$content" != "DATA_$i" ]; then
echo "CORRUPTION in par_$i: got '$content'"
fi
done
echo "PARALLEL:OK"
""")
assert "PARALLEL:OK" in res.stdout
assert "CORRUPTION" not in res.stdout
# X-6 — Atomic write does not leave .tmp files behind on success
def test_a3_atomic_write_leaves_no_tmp_on_success(mam_sandbox):
res = _bash(mam_sandbox, """
SHIM="$WORKSPACE_ROOT/.mam/shim/herdr"
DIR="$WORKSPACE_ROOT/.mam/shim"
"$SHIM" set-buffer -b "atomic_test" "ATOMIC_PAYLOAD"
ls -a "$DIR"
""")
assert "buf_atomic_test" in res.stdout
assert not any(f.endswith(".tmp") for f in res.stdout.split())
# X-7 — Abandoned buffers (older than GC threshold) are reclaimed
def test_a3_abandoned_buffers_are_collected(mam_sandbox):
res = _bash(mam_sandbox, """
DIR="$WORKSPACE_ROOT/.mam/shim"
mkdir -p "$DIR"
ABANDONED="$DIR/buf_sks_OLD_1_1"
: > "$ABANDONED"
touch -t 202501010000 "$ABANDONED"
SHIM="$DIR/herdr"
"$SHIM" set-buffer -b "trigger_gc" "NEW_DATA"
if [ -f "$ABANDONED" ]; then
echo "GC:LEAK"
else
echo "GC:SWEPT"
fi
""")
assert "GC:SWEPT" in res.stdout
# X-8 — Live buffers (newer than GC threshold) are preserved
def test_a3_live_buffers_are_preserved(mam_sandbox):
res = _bash(mam_sandbox, """
DIR="$WORKSPACE_ROOT/.mam/shim"
mkdir -p "$DIR"
LIVE="$DIR/buf_sks_LIVE_2_2"
echo "LIVE_CONTENT" > "$LIVE"
SHIM="$DIR/herdr"
"$SHIM" set-buffer -b "trigger_gc" "NEW_DATA"
if [ -f "$LIVE" ]; then
echo "LIVE:PRESERVED"
else
echo "LIVE:DELETED"
fi
""")
assert "LIVE:PRESERVED" in res.stdout
# X-9 — Sweep is scoped to buf_sks_* and does not touch non-matching files
def test_a3_sweep_is_scoped(mam_sandbox):
res = _bash(mam_sandbox, """
DIR="$WORKSPACE_ROOT/.mam/shim"
mkdir -p "$DIR"
OTHER="$DIR/other_old_file"
: > "$OTHER"
touch -t 202501010000 "$OTHER"
SHIM="$DIR/herdr"
"$SHIM" set-buffer -b "trigger_gc" "NEW_DATA"
if [ -f "$OTHER" ]; then
echo "OTHER:PRESERVED"
else
echo "OTHER:DELETED"
fi
""")
assert "OTHER:PRESERVED" in res.stdout
# X-10 — Abandoned atomic write DOT-temp files (.buf_sks_*.tmp) are collected
def test_a3_abandoned_dot_temps_are_collected(mam_sandbox):
res = _bash(mam_sandbox, """
DIR="$WORKSPACE_ROOT/.mam/shim"
mkdir -p "$DIR"
DOT_TEMP="$DIR/.buf_sks_ORPHAN_1_1.99.tmp"
: > "$DOT_TEMP"
touch -t 202501010000 "$DOT_TEMP"
SHIM="$DIR/herdr"
"$SHIM" set-buffer -b "trigger_gc" "NEW_DATA"
if [ -f "$DOT_TEMP" ]; then
echo "DOT_TEMP:LEAK"
else
echo "DOT_TEMP:SWEPT"
fi
""")
assert "DOT_TEMP:SWEPT" in res.stdout
# X-11 — Failed rename cleans up temp file immediately and fails with exit code 1
def test_a3_failed_rename_cleans_up_and_fails(mam_sandbox):
res = _bash(mam_sandbox, """
DIR="$WORKSPACE_ROOT/.mam/shim"
mkdir -p "$DIR"
# Block creation of buf_sks_blocked by placing a read-only directory in its place
BLOCK_DIR="$DIR/buf_sks_blocked"
mkdir -p "$BLOCK_DIR"
chmod 500 "$BLOCK_DIR"
SHIM="$DIR/herdr"
"$SHIM" set-buffer -b "sks_blocked" "PAYLOAD" 2>&1
RC=$?
echo "RC:$RC"
# Clean up permissions so test sandbox can clean up
chmod 700 "$BLOCK_DIR"
# Check if any .tmp files were left behind
ls -a "$DIR" | grep -q '\.tmp$' && echo "TEMP:LEAKED" || echo "TEMP:CLEAN"
""")
assert "RC:1" in res.stdout
assert "TEMP:CLEAN" in res.stdout
# X-12 — Scoped GC preserves unrelated dot-files
def test_a3_unrelated_dot_files_preserved(mam_sandbox):
res = _bash(mam_sandbox, """
DIR="$WORKSPACE_ROOT/.mam/shim"
mkdir -p "$DIR"
DOT_OTHER="$DIR/.some_other_dot_file"
: > "$DOT_OTHER"
touch -t 202501010000 "$DOT_OTHER"
SHIM="$DIR/herdr"
"$SHIM" set-buffer -b "trigger_gc" "NEW_DATA"
if [ -f "$DOT_OTHER" ]; then
echo "DOT_OTHER:PRESERVED"
else
echo "DOT_OTHER:DELETED"
fi
""")
assert "DOT_OTHER:PRESERVED" in res.stdout