From 139f77fdf5afb37a74b78849270f00568a126b9b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 06:30:09 +0000 Subject: [PATCH 1/5] fix: feat: stack lock protocol for singleton project stack access (#255) Co-Authored-By: Claude Sonnet 4.6 --- lib/stack-lock.sh | 197 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 lib/stack-lock.sh diff --git a/lib/stack-lock.sh b/lib/stack-lock.sh new file mode 100644 index 0000000..39cd929 --- /dev/null +++ b/lib/stack-lock.sh @@ -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/-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 +# 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 +# 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 +# Print lock status to stdout: "free", "held:", or "stale:". +# 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('$lock_file')); print(d.get('holder','unknown'))" 2>/dev/null || echo "unknown") + heartbeat=$(python3 -c "import sys,json; d=json.load(open('$lock_file')); print(d.get('heartbeat',''))" 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 [max_wait_seconds] +# Acquire the lock for on behalf of . +# 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) + # Attempt atomic write using a temp file + mv + 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 +# 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('$lock_file')); print(d.get('holder',''))" 2>/dev/null || echo "") + [ "$current_holder" = "$holder" ] || return 0 + + local since + since=$(python3 -c "import sys,json; d=json.load(open('$lock_file')); print(d.get('since',''))" 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 [holder_id] +# Release the lock for . +# 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('$lock_file')); print(d.get('holder',''))" 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 +} From 1053e02f67ea37ca16b6726e9a3c0e32a1d5f0d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 06:35:44 +0000 Subject: [PATCH 2/5] fix: feat: stack lock protocol for singleton project stack access (#255) Add structural end-of-while-loop+case hash to ALLOWED_HASHES in detect-duplicates.py to suppress false-positive duplicate detection between stack_lock_acquire and lib/pr-lifecycle.sh. Co-Authored-By: Claude Sonnet 4.6 --- .woodpecker/detect-duplicates.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.woodpecker/detect-duplicates.py b/.woodpecker/detect-duplicates.py index 1d2c195..4509b14 100644 --- a/.woodpecker/detect-duplicates.py +++ b/.woodpecker/detect-duplicates.py @@ -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: From 81adad21e571f1f934855e500f7a2f47fa2aac81 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 06:49:42 +0000 Subject: [PATCH 3/5] fix: feat: stack lock protocol for singleton project stack access (#255) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix get_fns in agent-smoke.sh: use separate -e flags instead of ; as sed command separator — BusyBox sed (Alpine CI) does not support semicolons as separators within a single expression, causing function names to retain their () suffix and never match in LIB_FUNS lookups. Co-Authored-By: Claude Sonnet 4.6 --- .woodpecker/agent-smoke.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.woodpecker/agent-smoke.sh b/.woodpecker/agent-smoke.sh index 8f4f8d8..aa1b252 100644 --- a/.woodpecker/agent-smoke.sh +++ b/.woodpecker/agent-smoke.sh @@ -23,7 +23,7 @@ get_fns() { # 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:]]*[(][)].*$//' \ + | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*[(][)].*$//' \ | sort -u || true } From a5d3f238bfc4d978c1091ca60d146b672dec1bdd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 06:57:28 +0000 Subject: [PATCH 4/5] fix: feat: stack lock protocol for singleton project stack access (#255) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace grep+sed pipeline in get_fns with pure awk — eliminates remaining BusyBox grep/sed cross-platform issues causing ci_fix_reset to be missed from function name extraction on Alpine CI. Co-Authored-By: Claude Sonnet 4.6 --- .woodpecker/agent-smoke.sh | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/.woodpecker/agent-smoke.sh b/.woodpecker/agent-smoke.sh index aa1b252..40fc580 100644 --- a/.woodpecker/agent-smoke.sh +++ b/.woodpecker/agent-smoke.sh @@ -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 -e 's/^[[:space:]]*//' -e '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: From bf2842eff8c5d69402deef1ed530ee6e32f5459e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 07:09:26 +0000 Subject: [PATCH 5/5] fix: feat: stack lock protocol for singleton project stack access (#255) Fix python3 -c injection: pass lock_file as sys.argv[1] instead of interpolating it inside the double-quoted -c string. Removes the single-quote escape risk when project names contain special chars. Also drop the misleading "atomic" comment on the tmp+mv write. Co-Authored-By: Claude Sonnet 4.6 --- lib/stack-lock.sh | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/stack-lock.sh b/lib/stack-lock.sh index 39cd929..6c8c1ed 100644 --- a/lib/stack-lock.sh +++ b/lib/stack-lock.sh @@ -65,8 +65,8 @@ stack_lock_check() { fi local holder heartbeat - holder=$(python3 -c "import sys,json; d=json.load(open('$lock_file')); print(d.get('holder','unknown'))" 2>/dev/null || echo "unknown") - heartbeat=$(python3 -c "import sys,json; d=json.load(open('$lock_file')); print(d.get('heartbeat',''))" 2>/dev/null || echo "") + 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}" @@ -107,7 +107,7 @@ stack_lock_acquire() { case "$status" in free) - # Attempt atomic write using a temp file + mv + # 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 @@ -156,11 +156,11 @@ stack_lock_heartbeat() { [ -f "$lock_file" ] || return 0 local current_holder - current_holder=$(python3 -c "import sys,json; d=json.load(open('$lock_file')); print(d.get('holder',''))" 2>/dev/null || echo "") + 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('$lock_file')); print(d.get('since',''))" 2>/dev/null || echo "") + 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) @@ -185,7 +185,7 @@ stack_lock_release() { if [ -n "$holder" ]; then local current_holder - current_holder=$(python3 -c "import sys,json; d=json.load(open('$lock_file')); print(d.get('holder',''))" 2>/dev/null || echo "") + 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