muse-crew 0.4.5 → 0.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/AGENTS.md CHANGED
@@ -4,12 +4,13 @@ Shell scripts for the crew's infrastructure. Called by workflow scripts, cron, a
4
4
 
5
5
  - `build-registry.js` — deterministic extractor that generates `workflows/registry.json` (workflow step registry) from the workflow files' `meta` blocks at release time; invoked by `crew-release.sh` deploy
6
6
  - `crew-release.sh` — immutable release manager: deploy, rollback, prune
7
- - `merge-lock.sh` — serialized merge lock for concurrent agents; records owner PID
8
- - `worktree-lifecycle.sh` — the worktree lifecycle seam: prepare/cleanup/inspect/integrate/status/post-deploy over git worktrees (`.worktrees/<id>`, branch `task/<id>`). The crew registry (`<repo>/.worktrees/.registry/<id>`) is the source of truth for task→branch/path — never reconstruct it from git state. Prepare fails closed on dirty `main`; cleanup is forgiving.
7
+ - `merge-lock.sh` — serialized merge lock for concurrent agents: time-based holder lease (bug 2fc8f52f — an unexpired lease is held regardless of process liveness; only an expired lease may be broken). Lock file is key=value: task_id, opaque holder identity (never a PID), acquired_at epoch, lease_seconds (default 600, override via MERGE_LOCK_LEASE_SECONDS). acquire/refresh/release/status/force-release; holder-only refresh and release; every op appends to $CREW_HOME/.merge-lock.log
8
+ - `worktree-lifecycle.sh` — the worktree lifecycle seam: prepare/cleanup/inspect/integrate/status/post-deploy/refresh-lock/lock-status over git worktrees (`.worktrees/<id>`, branch `task/<id>`). The crew registry (`<repo>/.worktrees/.registry/<id>`) is the source of truth for task→branch/path — never reconstruct it from git state. Prepare fails closed on dirty `main`; cleanup is forgiving. `lock-status` reports the merge-lock state explicitly (`UNLOCKED`, or `LOCKED by <holder> since <ts> (pid <pid>)`, always exit 0) so Publish can distinguish an empty-diff Integrate (no lock taken) from a refresh failure.
9
9
  - `test-worktree-backend.sh` — regression tests for the lifecycle script (validate, prepare/reuse, inspect, status, cleanup, idempotent cleanup, dirty-main preflight) on a scratch repo
10
- - `test-version-write.sh` — regression tests for the escape-preserving step-8 version write in publish-npm.sh (fixture: current package.json with the \u2014 escape; extracts the shipped block by anchor)
10
+ - `test-version-write.sh` — regression tests for the escape-preserving step-8 version write in publish-npm.sh (fixture: current package.json with the \\u2014 escape; extracts the shipped block by anchor)
11
11
  - `test-publish-verify.sh` — regression tests for the retry-tolerant step-12 verification in publish-npm.sh (canary 5a027278): extracts the shipped block by anchor and runs it against a fake npm whose read replica lags (non-zero exits, then the old version, then the target) — requires convergence on success, fail-closed `PUBLISH_FAILED=verify` on exhaustion, and `--prefer-online` on every read
12
- - `orphan-sweep.sh` — find and clean stale worktrees and merge locks
12
+ - `test-publish-skip.sh` — regression tests for the no-lock graceful publish skip (park 2026-09-11): `lock-status` returns exact `UNLOCKED`/`LOCKED by <holder> since <ts> (pid <pid>)`; a fake empty-diff integrate takes the MERGED_EMPTY branch and never takes the lock; the shipped step-5 block from publish-npm.sh (extracted by anchor) skips gracefully on UNLOCKED (no park, no `PUBLISH_COMPLETE`), follows the refresh path when a lock is held, and fails closed on a `lock-status` query failure; the gate sits inside the ALREADY_PUBLISHED=0 branch and mutation steps are gated on NO_LOCK_HELD=0
13
+ - `orphan-sweep.sh` — find and clean stale worktrees and merge locks: parses `merge-lock.sh status` key=value fields (never a PID); a lock whose task has a running session is untouched regardless of lease age; a lock with no running session is broken only when its lease is expired (via merge-lock.sh release so the break lands in the audit log)
13
14
  - `publish-npm.sh` — deterministic npm publish: lock refresh, release install, version write/commit, pack, registry publish, verify, push, post-deploy. Takes TARGET_VERSION as input; idempotent on retry/resume.
14
15
  - `compose-evidence.py` — deterministic visual-evidence compositor (Pillow): pairs identical PNG stems from baseline/ and postchange/ dirs, emits `<stem>-sidebyside.png` and amplified-difference `<stem>-overlay.png` into composites/, prints `PAIR`/`SKIP` manifests. Byte-deterministic; nonzero exit on errors.
15
16
  - `test-orphan-sweep.sh` — regression tests for orphan-sweep.sh (active-run guard, verified removal, fail-closed)
package/lib/merge-lock.sh CHANGED
@@ -1,16 +1,89 @@
1
1
  #!/usr/bin/env bash
2
- # Serialized merge lock for the orchestra-dashboard repo.
2
+ # merge-lock.sh — serialized merge lock for the crew's repos.
3
+ #
4
+ # Time-based holder lease (bug 2fc8f52f): the old PID-liveness staleness
5
+ # check never excluded anyone. The recorded PID was the short-lived shell
6
+ # that ran the acquire, so for essentially the whole Integrate→Publish→
7
+ # post-deploy hold window the holder's PID was dead, every concurrent
8
+ # acquire took the STALE path, and two Publish phases both believed they
9
+ # owned version assignment. PID is the wrong liveness signal (orphan-sweep
10
+ # already treats it as such) — the lease is time-based and refreshed by
11
+ # the workflow while the hold is live.
12
+ #
13
+ # The lock file ($CREW_REPO/.worktrees/.merge-lock) is key=value:
14
+ # task_id=<task id that holds the lock>
15
+ # holder=<opaque task+run identity, never a PID>
16
+ # acquired_at=<epoch seconds; refresh rewrites it to extend the lease>
17
+ # lease_seconds=<lease window; MERGE_LOCK_LEASE_SECONDS, default 600>
18
+ #
19
+ # The lock is HELD while now - acquired_at < lease_seconds. An expired
20
+ # (or malformed) lease is broken with logging and re-acquired. Every op
21
+ # appends one line to $CREW_HOME/.merge-lock.log (the audit trail).
22
+ #
3
23
  # Usage:
4
- # merge-lock.sh acquire <task_id> [pid]acquires lock, exits 0 on success, 1 if held
5
- # merge-lock.sh release <task_id> releases lock if held by this task
6
- # merge-lock.sh refresh <task_id> [pid] refresh lock timestamp (resets staleness clock)
7
- # merge-lock.sh status prints current lock holder or "unlocked"
8
- # merge-lock.sh force-release breaks the lock (admin use only)
24
+ # merge-lock.sh acquire <task_id> <holder>acquire the lock (0 ok, 1 held)
25
+ # merge-lock.sh refresh <task_id> extend the lease (holder task only)
26
+ # merge-lock.sh release <task_id> release the lock (holder task only)
27
+ # merge-lock.sh status key=value holder/age/remaining, or UNLOCKED
28
+ # merge-lock.sh force-release break the lock (admin use only)
9
29
 
10
30
  set -euo pipefail
11
31
 
12
32
  LOCK_DIR="${CREW_REPO:-$HOME/workspace/ts-spaces/orchestra-dashboard}/.worktrees"
13
33
  LOCK_FILE="$LOCK_DIR/.merge-lock"
34
+ LOG_FILE="${CREW_HOME:-$HOME/workspace/.jarvis}/.merge-lock.log"
35
+ LEASE_SECONDS="${MERGE_LOCK_LEASE_SECONDS:-600}"
36
+
37
+ log_op() { # log_op <op> <detail> — audit trail; logging never breaks a lock op
38
+ mkdir -p "$(dirname "$LOG_FILE")" 2>/dev/null || true
39
+ printf '%s op=%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" "$2" >> "$LOG_FILE" 2>/dev/null || true
40
+ }
41
+
42
+ # read_lock — parse the lock file into lock_task_id / lock_holder /
43
+ # lock_acquired_at / lock_lease_seconds. Returns 1 when there is no lock.
44
+ read_lock() {
45
+ lock_task_id=""; lock_holder=""; lock_acquired_at=""; lock_lease_seconds=""
46
+ [ -f "$LOCK_FILE" ] || return 1
47
+ local key val
48
+ while IFS='=' read -r key val; do
49
+ case "$key" in
50
+ task_id) lock_task_id="$val" ;;
51
+ holder) lock_holder="$val" ;;
52
+ acquired_at) lock_acquired_at="$val" ;;
53
+ lease_seconds) lock_lease_seconds="$val" ;;
54
+ esac
55
+ done < "$LOCK_FILE"
56
+ return 0
57
+ }
58
+
59
+ lock_age() { # seconds since acquired_at, or "unknown" when unparsable
60
+ if [[ "${lock_acquired_at:-}" =~ ^[0-9]+$ ]]; then
61
+ echo $(( $(date +%s) - lock_acquired_at ))
62
+ else
63
+ echo "unknown"
64
+ fi
65
+ }
66
+
67
+ lock_remaining() { # lease seconds remaining (<=0 means expired), or "unknown"
68
+ local age="${1:-$(lock_age)}" lease="$lock_lease_seconds"
69
+ [[ "$lease" =~ ^[0-9]+$ ]] || lease="$LEASE_SECONDS"
70
+ if [ "$age" = "unknown" ]; then
71
+ echo "unknown"
72
+ else
73
+ echo $(( lease - age ))
74
+ fi
75
+ }
76
+
77
+ # write_lock <task_id> <holder> — atomic create via noclobber; 0 on success.
78
+ # A concurrent writer wins exactly one create; the loser retries the read.
79
+ write_lock() {
80
+ local task_id="$1" holder="$2"
81
+ mkdir -p "$LOCK_DIR"
82
+ ( set -C
83
+ printf 'task_id=%s\nholder=%s\nacquired_at=%s\nlease_seconds=%s\n' \
84
+ "$task_id" "$holder" "$(date +%s)" "$LEASE_SECONDS" > "$LOCK_FILE"
85
+ ) 2>/dev/null
86
+ }
14
87
 
15
88
  cmd="${1:-}"
16
89
  task_id="${2:-}"
@@ -18,90 +91,98 @@ task_id="${2:-}"
18
91
  case "$cmd" in
19
92
  acquire)
20
93
  [ -z "$task_id" ] && { echo "ERROR: task_id required"; exit 1; }
21
- owner_pid="${3:-$PPID}"
22
- mkdir -p "$LOCK_DIR"
23
- if (set -C; echo "$task_id $(date -u +%Y-%m-%dT%H:%M:%SZ) $owner_pid" > "$LOCK_FILE") 2>/dev/null; then
24
- echo "ACQUIRED by $task_id"
94
+ holder="${3:-$task_id}"
95
+ if write_lock "$task_id" "$holder"; then
96
+ log_op acquire "task_id=$task_id holder=$holder result=ACQUIRED"
97
+ echo "ACQUIRED by $task_id (holder $holder)"
25
98
  exit 0
26
- else
27
- holder=$(cut -d' ' -f1 "$LOCK_FILE" 2>/dev/null || echo "unknown")
28
- acquired_at=$(cut -d' ' -f2 "$LOCK_FILE" 2>/dev/null || echo "unknown")
29
- lock_pid=$(awk '{print $3}' "$LOCK_FILE" 2>/dev/null || echo "-")
30
- # Self-healing: if the holder's PID is dead, missing, or invalid, the lock
31
- # is stale (the workflow that acquired it terminated without releasing
32
- # via post-deploy). Break the stale lock and retry the acquire. A live
33
- # PID means the lock is genuinely held — report HELD. This makes terminal
34
- # cleanup mechanical: a stopped/failed workflow's lock is reclaimed on
35
- # the next acquire, not via manual release or a background sweeper.
36
- if [ -z "$lock_pid" ] || [ "$lock_pid" = "-" ] || ! kill -0 "$lock_pid" 2>/dev/null; then
37
- echo "STALE: lock held by $holder since $acquired_at (pid $lock_pid dead) — breaking" >&2
38
- rm -f "$LOCK_FILE"
39
- if (set -C; echo "$task_id $(date -u +%Y-%m-%dT%H:%M:%SZ) $owner_pid" > "$LOCK_FILE") 2>/dev/null; then
40
- echo "ACQUIRED by $task_id (reclaimed stale lock from $holder)"
41
- exit 0
42
- fi
43
- # Lost the race: another task acquired it after we broke the stale lock.
44
- holder=$(cut -d' ' -f1 "$LOCK_FILE" 2>/dev/null || echo "unknown")
45
- acquired_at=$(cut -d' ' -f2 "$LOCK_FILE" 2>/dev/null || echo "unknown")
46
- lock_pid=$(awk '{print $3}' "$LOCK_FILE" 2>/dev/null || echo "-")
99
+ fi
100
+ # A lock file exists (or raced into existence) — read it.
101
+ if ! read_lock; then
102
+ # Raced with a release between create and read: retry the create once.
103
+ if write_lock "$task_id" "$holder"; then
104
+ log_op acquire "task_id=$task_id holder=$holder result=ACQUIRED"
105
+ echo "ACQUIRED by $task_id (holder $holder)"
106
+ exit 0
47
107
  fi
48
- echo "HELD by $holder since $acquired_at (pid $lock_pid)"
108
+ read_lock || { echo "ERROR: lock state unreadable"; log_op acquire "task_id=$task_id holder=$holder result=ERROR"; exit 1; }
109
+ fi
110
+ remaining="$(lock_remaining)"
111
+ if [ "$remaining" != "unknown" ] && [ "$remaining" -gt 0 ]; then
112
+ log_op acquire "task_id=$task_id holder=$holder result=HELD holder_task=$lock_task_id holder_id=$lock_holder remaining=${remaining}s"
113
+ echo "HELD by $lock_task_id (holder $lock_holder, ${remaining}s of lease remaining)"
49
114
  exit 1
50
115
  fi
116
+ # Expired or malformed lease — break it (logged) and re-acquire.
117
+ echo "STALE: lease for ${lock_task_id:-unknown} (holder ${lock_holder:-unknown}) expired — breaking" >&2
118
+ log_op acquire "task_id=$task_id holder=$holder result=STALE-BREAK previous_task=${lock_task_id:-unknown} previous_holder=${lock_holder:-unknown}"
119
+ rm -f "$LOCK_FILE"
120
+ if write_lock "$task_id" "$holder"; then
121
+ log_op acquire "task_id=$task_id holder=$holder result=ACQUIRED-RECLAIMED previous_task=${lock_task_id:-unknown}"
122
+ echo "ACQUIRED by $task_id (holder $holder; reclaimed expired lease from ${lock_task_id:-unknown})"
123
+ exit 0
124
+ fi
125
+ # Lost the race: whoever won the create holds it now.
126
+ read_lock 2>/dev/null || true
127
+ log_op acquire "task_id=$task_id holder=$holder result=HELD-RACE holder_task=${lock_task_id:-unknown}"
128
+ echo "HELD by ${lock_task_id:-unknown} (holder ${lock_holder:-unknown}) — lost reclaim race"
129
+ exit 1
130
+ ;;
131
+ refresh)
132
+ [ -z "$task_id" ] && { echo "ERROR: task_id required"; exit 1; }
133
+ if ! read_lock; then
134
+ log_op refresh "task_id=$task_id result=NO-LOCK"
135
+ echo "ERROR: no lock to refresh"
136
+ exit 1
137
+ fi
138
+ if [ "$lock_task_id" != "$task_id" ]; then
139
+ log_op refresh "task_id=$task_id result=NOT-HOLDER holder_task=$lock_task_id"
140
+ echo "ERROR: lock held by $lock_task_id, not $task_id"
141
+ exit 1
142
+ fi
143
+ # Extend the lease: acquired_at=now, holder and lease window unchanged.
144
+ printf 'task_id=%s\nholder=%s\nacquired_at=%s\nlease_seconds=%s\n' \
145
+ "$lock_task_id" "$lock_holder" "$(date +%s)" "${lock_lease_seconds:-$LEASE_SECONDS}" > "$LOCK_FILE"
146
+ log_op refresh "task_id=$task_id holder=$lock_holder result=REFRESHED"
147
+ echo "REFRESHED by $task_id (holder $lock_holder)"
148
+ exit 0
51
149
  ;;
52
150
  release)
53
151
  [ -z "$task_id" ] && { echo "ERROR: task_id required"; exit 1; }
54
- if [ -f "$LOCK_FILE" ]; then
55
- holder=$(cut -d' ' -f1 "$LOCK_FILE" 2>/dev/null || echo "")
56
- if [ "$holder" = "$task_id" ]; then
57
- rm -f "$LOCK_FILE"
58
- echo "RELEASED by $task_id"
59
- exit 0
60
- else
61
- echo "ERROR: lock held by $holder, not $task_id"
62
- exit 1
63
- fi
64
- else
152
+ if ! read_lock; then
153
+ log_op release "task_id=$task_id result=NOT-LOCKED"
65
154
  echo "RELEASED (was not locked)"
66
155
  exit 0
67
156
  fi
157
+ if [ "$lock_task_id" = "$task_id" ]; then
158
+ rm -f "$LOCK_FILE"
159
+ log_op release "task_id=$task_id holder=$lock_holder result=RELEASED"
160
+ echo "RELEASED by $task_id"
161
+ exit 0
162
+ fi
163
+ log_op release "task_id=$task_id result=NOT-HOLDER holder_task=$lock_task_id"
164
+ echo "ERROR: lock held by $lock_task_id, not $task_id"
165
+ exit 1
68
166
  ;;
69
167
  status)
70
- if [ -f "$LOCK_FILE" ]; then
71
- holder=$(cut -d' ' -f1 "$LOCK_FILE" 2>/dev/null || echo "unknown")
72
- acquired_at=$(cut -d' ' -f2 "$LOCK_FILE" 2>/dev/null || echo "unknown")
73
- lock_pid=$(awk '{print $3}' "$LOCK_FILE" 2>/dev/null || echo "-")
74
- echo "LOCKED by $holder since $acquired_at (pid $lock_pid)"
75
- else
168
+ if ! read_lock; then
76
169
  echo "UNLOCKED"
170
+ exit 0
77
171
  fi
172
+ printf 'locked=true\ntask_id=%s\nholder=%s\nacquired_at=%s\nlease_seconds=%s\nage_seconds=%s\nremaining_seconds=%s\n' \
173
+ "$lock_task_id" "$lock_holder" "$lock_acquired_at" "${lock_lease_seconds:-$LEASE_SECONDS}" \
174
+ "$(lock_age)" "$(lock_remaining)"
78
175
  exit 0
79
176
  ;;
80
177
  force-release)
178
+ read_lock 2>/dev/null || true
179
+ log_op force-release "result=FORCE-RELEASED previous_task=${lock_task_id:-unknown}"
81
180
  rm -f "$LOCK_FILE"
82
181
  echo "FORCE RELEASED"
83
182
  exit 0
84
183
  ;;
85
- refresh)
86
- [ -z "$task_id" ] && { echo "ERROR: task_id required"; exit 1; }
87
- new_pid="${3:-$PPID}"
88
- if [ -f "$LOCK_FILE" ]; then
89
- holder=$(cut -d' ' -f1 "$LOCK_FILE" 2>/dev/null || echo "")
90
- if [ "$holder" = "$task_id" ]; then
91
- echo "$task_id $(date -u +%Y-%m-%dT%H:%M:%SZ) $new_pid" > "$LOCK_FILE"
92
- echo "REFRESHED by $task_id"
93
- exit 0
94
- else
95
- echo "ERROR: lock held by $holder, not $task_id"
96
- exit 1
97
- fi
98
- else
99
- echo "ERROR: no lock to refresh"
100
- exit 1
101
- fi
102
- ;;
103
184
  *)
104
- echo "Usage: merge-lock.sh {acquire|release|refresh|status|force-release} [task_id] [pid]"
185
+ echo "Usage: merge-lock.sh {acquire|release|refresh|status|force-release} <task_id> [holder]"
105
186
  exit 1
106
187
  ;;
107
188
  esac
@@ -4,9 +4,9 @@
4
4
  #
5
5
  # Usage:
6
6
  # orphan-sweep.sh report — list orphans (read-only for worktrees);
7
- # stale locks with dead PIDs are released
7
+ # expired-lease locks are released
8
8
  # orphan-sweep.sh clean — remove safe-to-clean orphans (merged branches only),
9
- # release stale merge locks, and remove pins whose
9
+ # release expired-lease merge locks, and remove pins whose
10
10
  # task has no running session
11
11
  #
12
12
  # Active-run knowledge is injected by the caller, never fetched here:
@@ -44,7 +44,7 @@ set -euo pipefail
44
44
  REPO="${CREW_REPO:-$HOME/workspace/ts-spaces/orchestra-dashboard}"
45
45
  WORKTREE_DIR="$REPO/.worktrees"
46
46
  LOCK_FILE="$WORKTREE_DIR/.merge-lock"
47
- STALE_LOCK_MIN=30
47
+ MERGE_LOCK_SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/merge-lock.sh"
48
48
 
49
49
  cmd="${1:-report}"
50
50
  found=0
@@ -100,40 +100,44 @@ if [ "$cmd" != "clean" ] && [ "$have_active_data" -eq 0 ]; then
100
100
  echo "NOTE: no active-run data provided; ACTIVE checks skipped"
101
101
  fi
102
102
 
103
- # --- Stale merge lock ---
103
+ # --- Stale merge lock (lease-based; bug 2fc8f52f) ---
104
+ # Owner identity comes from merge-lock.sh status key=value fields — never
105
+ # from a PID. Fail-closed: a lock whose task has a running session is
106
+ # untouched regardless of lease age; a lock whose task has no running
107
+ # session is broken only when its lease is expired. The break goes through
108
+ # merge-lock.sh release (or force-release for an unparseable file with no
109
+ # identifiable holder) so it lands in the merge-lock audit log.
104
110
  if [ -f "$LOCK_FILE" ]; then
105
- lock_holder=$(cut -d' ' -f1 "$LOCK_FILE" 2>/dev/null || echo "unknown")
106
- if task_is_active "$lock_holder"; then
107
- echo "ACTIVE_LOCK: held by $lock_holder (active run on dashboard)skipping"
108
- found=1
111
+ lock_status="$("$MERGE_LOCK_SCRIPT" status 2>/dev/null || echo UNLOCKED)"
112
+ if [ "$lock_status" = "UNLOCKED" ]; then
113
+ : # lock file vanished between the check and the read nothing held
109
114
  else
110
- lock_time=$(cut -d' ' -f2 "$LOCK_FILE" 2>/dev/null || echo "")
111
- lock_pid=$(awk '{print $3}' "$LOCK_FILE" 2>/dev/null || echo "")
112
- if [ -n "$lock_time" ]; then
113
- lock_epoch=$(date -d "$lock_time" +%s 2>/dev/null || echo 0)
114
- now_epoch=$(date -u +%s)
115
- age_min=$(( (now_epoch - lock_epoch) / 60 ))
116
- if [ "$age_min" -gt "$STALE_LOCK_MIN" ]; then
117
- # Check if owner process is still alive
118
- pid_alive=0
119
- if [ -n "$lock_pid" ] && [ "$lock_pid" != "-" ] && kill -0 "$lock_pid" 2>/dev/null; then
120
- pid_alive=1
121
- fi
122
-
123
- if [ "$pid_alive" -eq 1 ]; then
124
- echo "ACTIVE_LOCK: held ${age_min}m by $lock_holder (pid $lock_pid alive) — skipping"
125
- else
126
- echo "STALE_LOCK: held ${age_min}m by $lock_holder (pid ${lock_pid:-none} dead) — threshold ${STALE_LOCK_MIN}m"
127
- found=1
128
- rm -f "$LOCK_FILE"
129
- if [ ! -f "$LOCK_FILE" ]; then
130
- echo " → released (dead PID)"
131
- else
132
- echo " → FAILED: lock file still present after rm"
133
- any_failed=1
134
- fi
135
- fi
115
+ lock_holder=$(printf '%s\n' "$lock_status" | sed -n 's/^task_id=//p' | head -1)
116
+ lock_remaining=$(printf '%s\n' "$lock_status" | sed -n 's/^remaining_seconds=//p' | head -1)
117
+ lock_age=$(printf '%s\n' "$lock_status" | sed -n 's/^age_seconds=//p' | head -1)
118
+ if task_is_active "$lock_holder"; then
119
+ echo "ACTIVE_LOCK: held by ${lock_holder:-unknown} (active run on dashboard) — skipping"
120
+ found=1
121
+ elif [ "${lock_remaining:-unknown}" != "unknown" ] && [ "$lock_remaining" -gt 0 ]; then
122
+ echo "HELD_LOCK: held by ${lock_holder:-unknown} (lease ${lock_remaining}s remaining) — skipping"
123
+ found=1
124
+ else
125
+ echo "STALE_LOCK: held by ${lock_holder:-unknown} (age ${lock_age:-unknown}s, lease expired, no running session)"
126
+ found=1
127
+ if [ -n "$lock_holder" ]; then
128
+ rel_out=$("$MERGE_LOCK_SCRIPT" release "$lock_holder" 2>&1) || true
129
+ else
130
+ rel_out=$("$MERGE_LOCK_SCRIPT" force-release 2>&1) || true
136
131
  fi
132
+ case "$rel_out" in
133
+ *RELEASED*)
134
+ echo " → released (lease expired)"
135
+ ;;
136
+ *)
137
+ echo " → FAILED: could not release lock: $rel_out"
138
+ any_failed=1
139
+ ;;
140
+ esac
137
141
  fi
138
142
  fi
139
143
  fi