idle-draft: process-group child termination, unique tmp paths, stale sweep

Lock-contention test added; children die with the dispatcher (SIGTERM/
SIGINT handlers + atexit); tmp files are per-invocation (.tmp.<pid>) so
a SIGKILL-orphaned child can never collide with a new instance.

Claude-Session: https://claude.ai/code/session_01YQDoWNM7XPPii28khFWoMc
This commit is contained in:
Paul O'Reilly
2026-08-02 21:52:17 +12:00
parent 75add58246
commit 1f008eebcb
3 changed files with 363 additions and 13 deletions

View File

@@ -162,6 +162,50 @@ after_hash=$(cd "$FIXTURE_REPO" && git rev-parse HEAD)
[[ "$before_hash" == "$after_hash" ]] && pass "dryrun makes no git commits" || fail "dryrun makes no git commits"
[[ ! -f "$FIXTURE_REPO/ai/03-topic-three.review.md" ]] && pass "dryrun writes no output file" || fail "dryrun writes no output file"
echo "-- concurrency: second invocation exits 0 when lock is held (GAP 1) --"
# Holds .idle-draft.lock in a background helper (independent process, so the
# flock is real -- fcntl locks are per-process/per-fd, a second flock from
# this same bash process wouldn't contend). The helper touches a sentinel
# file only *after* it has the lock, so we never race the flock call itself.
LOCK_HELPER="$TMPDIR/lock_helper.py"
cat > "$LOCK_HELPER" <<'PYEOF'
import fcntl, sys, time
lock_path, sentinel_path = sys.argv[1], sys.argv[2]
f = open(lock_path, "w")
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
with open(sentinel_path, "w") as s:
s.write("locked\n")
time.sleep(30)
PYEOF
LOCK_SENTINEL="$TMPDIR/lock-sentinel"
rm -f "$LOCK_SENTINEL"
python3 "$LOCK_HELPER" "$FIXTURE_REPO/.idle-draft.lock" "$LOCK_SENTINEL" &
HELPER_PID=$!
waited=0
while [[ ! -f "$LOCK_SENTINEL" && $waited -lt 50 ]]; do
sleep 0.1
waited=$((waited + 1))
done
if [[ ! -f "$LOCK_SENTINEL" ]]; then
fail "lock-contention test setup: helper never confirmed it holds the lock"
else
before_hash=$(cd "$FIXTURE_REPO" && git rev-parse HEAD)
# Real dispatch mode (no --dryrun): a fixture with plenty of idle capacity,
# so absent the lock this would dispatch item 03's review task for real.
output=$("$SCRIPT" --config "$FIXTURE_CONFIG" --repo "$FIXTURE_REPO" --probe-json "$FIXTURE_PROBE" --once 2>&1)
code=$?
assert_exit_code "$code" 0 "second invocation with lock held exits 0"
assert_contains "$output" "lock held" "log line reports lock held"
after_hash=$(cd "$FIXTURE_REPO" && git rev-parse HEAD)
[[ "$before_hash" == "$after_hash" ]] && pass "no commit occurred while lock held" || fail "no commit occurred while lock held"
[[ ! -f "$FIXTURE_REPO/ai/03-topic-three.review.md" ]] && pass "no output file written while lock held" || fail "no output file written while lock held"
fi
kill "$HELPER_PID" 2>/dev/null
wait "$HELPER_PID" 2>/dev/null
echo "-- state validation failure exits 2 --"
BADSTATE="$TMPDIR/badstate-repo"
mkdir -p "$BADSTATE/ai"
@@ -547,6 +591,85 @@ except m.StateValidationError:
m.validate_state({"items": {"ai/01-topic-one": {"human_edit_done": True, "attempts": {"draft": 1}}}}, repo)
check("state validation: well-formed state passes", True)
# --- GAP 2b: unique tmp path per invocation ---
import os as _os
import time as _time
canonical = repo / "ai" / "03-topic-three.review.md"
tmp_a = m.tmp_output_path(canonical, pid=11111)
tmp_b = m.tmp_output_path(canonical, pid=22222)
check("tmp path uniqueness: two different pids produce two different tmp paths",
tmp_a != tmp_b and tmp_a.name == "03-topic-three.review.md.tmp.11111"
and tmp_b.name == "03-topic-three.review.md.tmp.22222", (str(tmp_a), str(tmp_b)))
tmp_default = m.tmp_output_path(canonical)
check("tmp path uniqueness: default pid is os.getpid()",
tmp_default.name == f"03-topic-three.review.md.tmp.{_os.getpid()}", str(tmp_default))
rejected = m.rejected_output_path(canonical)
check("rejected-path derivation is unaffected by the new tmp naming (derived from canonical, not tmp)",
rejected.name == "03-topic-three.review.md.rejected", str(rejected))
# --- GAP 2b: startup sweep of stale tmp files ---
sweep_repo = tmpdir / "sweep-repo"
(sweep_repo / "ai").mkdir(parents=True, exist_ok=True)
old_tmp = sweep_repo / "ai" / "01-a.research.md.tmp.111"
fresh_tmp = sweep_repo / "ai" / "02-b.research.md.tmp.222"
old_tmp.write_text("stale orphan output\n")
fresh_tmp.write_text("still-in-progress output\n")
now = _time.time()
task_timeout = 1800
old_mtime = now - task_timeout - 100 # older than the timeout -> stale
fresh_mtime = now - 10 # well within the timeout -> live orphan candidate, keep
_os.utime(old_tmp, (old_mtime, old_mtime))
_os.utime(fresh_tmp, (fresh_mtime, fresh_mtime))
deleted = m.sweep_stale_tmp_files(sweep_repo, ["ai"], task_timeout, now=now)
check("stale sweep: deletes a tmp file older than the task timeout",
old_tmp in deleted and not old_tmp.exists(), deleted)
check("stale sweep: leaves a tmp file younger than the task timeout untouched",
fresh_tmp not in deleted and fresh_tmp.exists(), deleted)
# A non-.tmp.<pid> file (e.g. the canonical output itself) must never be swept.
canonical_lookalike = sweep_repo / "ai" / "01-a.research.md"
canonical_lookalike.write_text("# Research: a\n")
_os.utime(canonical_lookalike, (old_mtime, old_mtime))
deleted2 = m.sweep_stale_tmp_files(sweep_repo, ["ai"], task_timeout, now=now)
check("stale sweep: never touches a file without a .tmp.<pid> suffix, however old",
canonical_lookalike.exists() and canonical_lookalike not in deleted2, canonical_lookalike)
# --- GAP 2a: child process-group tracking / signal handling (smoke tests) ---
# No real subprocess is spawned here -- just the bookkeeping primitives that
# the signal handler and run_task() rely on.
fake_pid = 2**30 # astronomically unlikely to collide with a real pid/pgid
m.register_child(fake_pid)
check("child tracking: register_child adds the pid to the live set", fake_pid in m.live_children(), m.live_children())
m.unregister_child(fake_pid)
check("child tracking: unregister_child removes the pid", fake_pid not in m.live_children(), m.live_children())
m.register_child(fake_pid)
m.register_child(fake_pid + 1)
try:
m.kill_all_children()
kill_all_ok = True
except Exception as e:
kill_all_ok = False
check("child tracking: kill_all_children on nonexistent pids raises nothing (ProcessLookupError swallowed)",
kill_all_ok, "")
check("child tracking: kill_all_children clears the registry", m.live_children() == set(), m.live_children())
m.install_signal_handlers()
import signal as _signal
check("signal handling: install_signal_handlers installs a non-default SIGTERM handler",
_signal.getsignal(_signal.SIGTERM) not in (_signal.SIG_DFL, _signal.SIG_IGN, None), "")
check("signal handling: install_signal_handlers installs a non-default SIGINT handler",
_signal.getsignal(_signal.SIGINT) not in (_signal.SIG_DFL, _signal.SIG_IGN, None), "")
# Restore defaults so this subprocess (which is about to exit anyway) doesn't
# leave anything surprising behind for the interpreter teardown.
_signal.signal(_signal.SIGTERM, _signal.SIG_DFL)
_signal.signal(_signal.SIGINT, _signal.SIG_DFL)
for status, name, detail in results:
print(f"{status}: {name}", detail if detail else "")