fix: feat: stack lock protocol for singleton project stack access (#255) #270

Merged
dev-qwen merged 5 commits from fix/issue-255 into main 2026-04-06 07:13:48 +00:00
3 changed files with 210 additions and 6 deletions

View file

@ -19,12 +19,16 @@ FAILED=0
# Uses awk instead of grep -Eo for busybox/Alpine compatibility (#296).
get_fns() {
local f="$1"
# BRE mode (no -E). Use [(][)] for literal parens — unambiguous across
# GNU grep and BusyBox grep (some BusyBox builds treat bare () as grouping
# even in BRE). BRE one-or-more via [X][X]* instead of +.
grep '^[[:space:]]*[a-zA-Z_][a-zA-Z0-9_][a-zA-Z0-9_]*[[:space:]]*[(][)]' "$f" 2>/dev/null \
| sed 's/^[[:space:]]*//; s/[[:space:]]*[(][)].*$//' \
| sort -u || true
# Pure-awk implementation: avoids grep/sed cross-platform differences
# (BusyBox grep BRE quirks, sed ; separator issues on Alpine).
awk '
/^[[:space:]]*[a-zA-Z_][a-zA-Z0-9_][a-zA-Z0-9_]*[[:space:]]*[(][)]/ {
line = $0
gsub(/^[[:space:]]+/, "", line)
sub(/[[:space:]]*[(].*/, "", line)
print line
}
' "$f" 2>/dev/null | sort -u || true
}
# Extract call-position identifiers that look like custom function calls:

View file

@ -302,6 +302,9 @@ def main() -> int:
"f08a7139db9c96cd3526549c499c0332": "install_project_crons function in entrypoints (window f08a7139)",
"f0917809bdf28ff93fff0749e7e7fea0": "install_project_crons function in entrypoints (window f0917809)",
"f0e4101f9b90c2fa921e088057a96db7": "install_project_crons function in entrypoints (window f0e4101f)",
# Structural end-of-while-loop+case pattern: `return 1 ;; esac done }`
# Appears in stack_lock_acquire (lib/stack-lock.sh) and lib/pr-lifecycle.sh
"29d4f34b703f44699237713cc8d8065b": "Structural end-of-while-loop+case (return 1, esac, done, closing brace)",
}
if not sh_files:

197
lib/stack-lock.sh Normal file
View file

@ -0,0 +1,197 @@
#!/usr/bin/env bash
# stack-lock.sh — File-based lock protocol for singleton project stack access
#
# Prevents CI pipelines and the reproduce-agent from stepping on each other
# when sharing a single project stack (e.g. harb docker compose).
#
# Lock file: /home/agent/data/locks/<project>-stack.lock
# Contents: {"holder": "reproduce-agent-42", "since": "...", "heartbeat": "..."}
#
# Protocol:
# 1. stack_lock_check — inspect current lock state
# 2. stack_lock_acquire — wait until lock is free, then claim it
# 3. stack_lock_release — delete lock file when done
#
# Heartbeat: callers must update the heartbeat every 2 minutes while holding
# the lock by calling stack_lock_heartbeat. A heartbeat older than 10 minutes
# is considered stale — the next acquire will break it.
#
# Usage:
# source "$(dirname "$0")/../lib/stack-lock.sh"
# stack_lock_acquire "ci-pipeline-$BUILD_NUMBER" "myproject"
# trap 'stack_lock_release "myproject"' EXIT
# # ... do work ...
# stack_lock_release "myproject"
set -euo pipefail
STACK_LOCK_DIR="${HOME}/data/locks"
STACK_LOCK_POLL_INTERVAL=30 # seconds between retry polls
STACK_LOCK_STALE_SECONDS=600 # 10 minutes — heartbeat older than this = stale
STACK_LOCK_MAX_WAIT=3600 # 1 hour — give up after this many seconds
# _stack_lock_path <project>
# Print the path of the lock file for the given project.
_stack_lock_path() {
local project="$1"
echo "${STACK_LOCK_DIR}/${project}-stack.lock"
}
# _stack_lock_now
# Print current UTC timestamp in ISO-8601 format.
_stack_lock_now() {
date -u +"%Y-%m-%dT%H:%M:%SZ"
}
# _stack_lock_epoch <iso_timestamp>
# Convert an ISO-8601 UTC timestamp to a Unix epoch integer.
_stack_lock_epoch() {
local ts="$1"
# Strip trailing Z, replace T with space for `date -d`
date -u -d "${ts%Z}" +%s 2>/dev/null || date -u -j -f "%Y-%m-%dT%H:%M:%S" "${ts%Z}" +%s 2>/dev/null
}
# stack_lock_check <project>
# Print lock status to stdout: "free", "held:<holder>", or "stale:<holder>".
# Returns 0 in all cases (status is in stdout).
stack_lock_check() {
local project="$1"
local lock_file
lock_file="$(_stack_lock_path "$project")"
if [ ! -f "$lock_file" ]; then
echo "free"
return 0
fi
local holder heartbeat
holder=$(python3 -c 'import sys,json; d=json.load(open(sys.argv[1])); print(d.get("holder","unknown"))' "$lock_file" 2>/dev/null || echo "unknown")
heartbeat=$(python3 -c 'import sys,json; d=json.load(open(sys.argv[1])); print(d.get("heartbeat",""))' "$lock_file" 2>/dev/null || echo "")
if [ -z "$heartbeat" ]; then
echo "stale:${holder}"
return 0
fi
local hb_epoch now_epoch age
hb_epoch=$(_stack_lock_epoch "$heartbeat" 2>/dev/null || echo "0")
now_epoch=$(date -u +%s)
age=$(( now_epoch - hb_epoch ))
if [ "$age" -gt "$STACK_LOCK_STALE_SECONDS" ]; then
echo "stale:${holder}"
else
echo "held:${holder}"
fi
}
# stack_lock_acquire <holder_id> <project> [max_wait_seconds]
# Acquire the lock for <project> on behalf of <holder_id>.
# Polls every STACK_LOCK_POLL_INTERVAL seconds.
# Breaks stale locks automatically.
# Exits non-zero if the lock cannot be acquired within max_wait_seconds.
stack_lock_acquire() {
local holder="$1"
local project="$2"
local max_wait="${3:-$STACK_LOCK_MAX_WAIT}"
local lock_file
lock_file="$(_stack_lock_path "$project")"
local deadline
deadline=$(( $(date -u +%s) + max_wait ))
mkdir -p "$STACK_LOCK_DIR"
while true; do
local status
status=$(stack_lock_check "$project")
case "$status" in
free)
# Write to temp file then rename to avoid partial reads by other processes
local tmp_lock
tmp_lock=$(mktemp "${STACK_LOCK_DIR}/.lock-tmp-XXXXXX")
local now
now=$(_stack_lock_now)
printf '{"holder": "%s", "since": "%s", "heartbeat": "%s"}\n' \
"$holder" "$now" "$now" > "$tmp_lock"
mv "$tmp_lock" "$lock_file"
echo "[stack-lock] acquired lock for ${project} as ${holder}" >&2
return 0
;;
stale:*)
local stale_holder="${status#stale:}"
echo "[stack-lock] breaking stale lock held by ${stale_holder} for ${project}" >&2
rm -f "$lock_file"
# Loop back immediately to re-check and claim
;;
held:*)
local cur_holder="${status#held:}"
local remaining
remaining=$(( deadline - $(date -u +%s) ))
if [ "$remaining" -le 0 ]; then
echo "[stack-lock] timed out waiting for lock on ${project} (held by ${cur_holder})" >&2
return 1
fi
echo "[stack-lock] ${project} locked by ${cur_holder}, waiting ${STACK_LOCK_POLL_INTERVAL}s (${remaining}s left)..." >&2
sleep "$STACK_LOCK_POLL_INTERVAL"
;;
*)
echo "[stack-lock] unexpected status '${status}' for ${project}" >&2
return 1
;;
esac
done
}
# stack_lock_heartbeat <holder_id> <project>
# Update the heartbeat timestamp in the lock file.
# Should be called every 2 minutes while holding the lock.
# No-op if the lock file is absent or held by a different holder.
stack_lock_heartbeat() {
local holder="$1"
local project="$2"
local lock_file
lock_file="$(_stack_lock_path "$project")"
[ -f "$lock_file" ] || return 0
local current_holder
current_holder=$(python3 -c 'import sys,json; d=json.load(open(sys.argv[1])); print(d.get("holder",""))' "$lock_file" 2>/dev/null || echo "")
[ "$current_holder" = "$holder" ] || return 0
local since
since=$(python3 -c 'import sys,json; d=json.load(open(sys.argv[1])); print(d.get("since",""))' "$lock_file" 2>/dev/null || echo "")
local now
now=$(_stack_lock_now)
local tmp_lock
tmp_lock=$(mktemp "${STACK_LOCK_DIR}/.lock-tmp-XXXXXX")
printf '{"holder": "%s", "since": "%s", "heartbeat": "%s"}\n' \
"$holder" "$since" "$now" > "$tmp_lock"
mv "$tmp_lock" "$lock_file"
}
# stack_lock_release <project> [holder_id]
# Release the lock for <project>.
# If holder_id is provided, only releases if the lock is held by that holder
# (prevents accidentally releasing someone else's lock).
stack_lock_release() {
local project="$1"
local holder="${2:-}"
local lock_file
lock_file="$(_stack_lock_path "$project")"
[ -f "$lock_file" ] || return 0
if [ -n "$holder" ]; then
local current_holder
current_holder=$(python3 -c 'import sys,json; d=json.load(open(sys.argv[1])); print(d.get("holder",""))' "$lock_file" 2>/dev/null || echo "")
if [ "$current_holder" != "$holder" ]; then
echo "[stack-lock] refusing to release: lock held by '${current_holder}', not '${holder}'" >&2
return 1
fi
fi
rm -f "$lock_file"
echo "[stack-lock] released lock for ${project}" >&2
}