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.
@@ -23,6 +23,9 @@
23
23
  # On success: print the markers below, exit 0. The final marker is
24
24
  # PUBLISH_COMPLETE=<version> — the Publish agent pastes it verbatim into its
25
25
  # summary as the script's completion marker.
26
+ # When no merge lock is held (empty-diff Integrate), the publish path is
27
+ # skipped gracefully: PUBLISH_SKIPPED=no-lock-held is printed, no version
28
+ # is written or published, and PUBLISH_COMPLETE is omitted.
26
29
 
27
30
  set -euo pipefail
28
31
 
@@ -65,115 +68,143 @@ if { [ "$HEAD_MSG" = "release: $PKG@$TARGET_VERSION ($TASK_ID)" ] \
65
68
  echo "PUBLISH_VERIFIED=$TARGET_VERSION"
66
69
  fi
67
70
 
71
+ NO_LOCK_HELD=0
68
72
  if [ "$ALREADY_PUBLISHED" = "0" ]; then
69
- # 5. Refresh the merge lock (held from Integrate through post-deploy).
70
- LOCK_OUT="$(CREW_REPO="$REPO_PATH" bash "${LIFECYCLE:?LIFECYCLE is required}" refresh-lock "$TASK_ID" 2>&1)" \
71
- || fail "lock-refresh" "$LOCK_OUT"
72
-
73
- # 6. Install/activate the immutable release.
74
- REL_OUT="$(bash "${RELEASE_SCRIPT:?RELEASE_SCRIPT is required}" deploy "$REPO_PATH" 2>&1)" \
75
- || fail "release-deploy" "$REL_OUT"
76
- case "$REL_OUT" in
77
- *INSTALLED*ACTIVATED*|*EXISTS*ACTIVATED*) ;;
78
- *) fail "release-deploy" "unexpected output: $REL_OUT" ;;
79
- esac
80
-
81
- # 7. Read the old version (audit only — TARGET_VERSION stays authoritative).
82
- OLD="$(git -C "$REPO_PATH" show "HEAD:package.json" \
83
- | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).version")" \
84
- || fail "read-base" "could not read version from HEAD:package.json"
85
- echo "PUBLISH_BASE=$OLD"
86
-
87
- # 8. Write TARGET_VERSION into package.json (`version` field only) via
88
- # byte-preserving text substitution. A JSON.parse/stringify round-trip is
89
- # forbidden here: it expands unicode escapes (e.g. the \u2014 escape in
90
- # the description field) into literal characters, producing a two-line
91
- # diff that trips the preservation check below (canary 5a027278). The
92
- # substitution touches exactly one line; a missed substitution yields an
93
- # empty diff and fails closed at the numstat check.
94
- # STEP-8-ANCHOR: version write (escape-preserving)
95
- PKG_JSON="$REPO_PATH/package.json"
96
- ESCAPED_VER="$(printf '%s' "$TARGET_VERSION" | sed 's/[&\\]/\\&/g')"
97
- sed -i -E 's/^([[:space:]]*"version"[[:space:]]*:[[:space:]]*)"[^"]*"/\1"'"$ESCAPED_VER"'"/' "$PKG_JSON" \
98
- || fail "version-write" "sed could not write $TARGET_VERSION into package.json"
99
- # Read-only verification: parse package.json and confirm .version equals
100
- # TARGET_VERSION. (Parse only — no rewrite, so no clock/randomness.)
101
- NEW_VER="$(PKG_JSON="$PKG_JSON" node -p "JSON.parse(require('fs').readFileSync(process.env.PKG_JSON,'utf8')).version")" \
102
- || fail "version-write" "could not parse package.json after write"
103
- [ "$NEW_VER" = "$TARGET_VERSION" ] \
104
- || fail "version-write" "package.json version is $NEW_VER, expected $TARGET_VERSION"
105
- echo "PUBLISH_DIFF:"
106
- git -C "$REPO_PATH" diff -- package.json
107
- ADD_DEL="$(git -C "$REPO_PATH" diff --numstat -- package.json | tr '\t' ' ')"
108
- [ "$ADD_DEL" = "1 1 package.json" ] \
109
- || fail "version-diff" "expected a single-line version change, got: $ADD_DEL"
110
- # STEP-8-END
111
-
112
- # 9. Commit only if changed (retried run leaves the commit in place).
113
- if git -C "$REPO_PATH" diff --quiet -- package.json; then
114
- echo "PUBLISH_COMMIT_EXISTS"
73
+ # STEP-5-ANCHOR: lock-state gate (empty-diff Integrate takes no lock).
74
+ # An empty-diff Integrate (MERGED_EMPTY) intentionally takes no merge
75
+ # lock: nothing merged, nothing to ship. When no lock is held, Publish
76
+ # skips the publish path gracefully instead of failing closed on
77
+ # refresh-lock (park 2026-09-11). UNLOCKED is explicit state, not prose.
78
+ # The anchor sits inside the ALREADY_PUBLISHED=0 branch, so the skip
79
+ # marker prints only on a genuinely unheld lock — a resumed run whose
80
+ # post-deploy already released the lock prints nothing here; its
81
+ # PUBLISHED_ALREADY/PUBLISH_VERIFIED markers already say it all.
82
+ LOCK_STATE="$(CREW_REPO="$REPO_PATH" bash "${LIFECYCLE:?LIFECYCLE is required}" lock-status "$TASK_ID" 2>&1)" \
83
+ || fail "lock-status" "$LOCK_STATE"
84
+ if [ "$LOCK_STATE" = "UNLOCKED" ]; then
85
+ NO_LOCK_HELD=1
86
+ echo "PUBLISH_SKIPPED=no-lock-held"
115
87
  else
116
- git -C "$REPO_PATH" add package.json \
117
- || fail "commit" "git add package.json failed"
118
- git -C "$REPO_PATH" commit -m "release: $PKG@$TARGET_VERSION ($TASK_ID)" >/dev/null \
119
- || fail "commit" "git commit failed"
120
- echo "PUBLISH_COMMIT=$(git -C "$REPO_PATH" rev-parse HEAD)"
88
+ # 5b. Refresh the merge lock (held from Integrate through post-deploy).
89
+ LOCK_OUT="$(CREW_REPO="$REPO_PATH" bash "${LIFECYCLE:?LIFECYCLE is required}" refresh-lock "$TASK_ID" 2>&1)" \
90
+ || fail "lock-refresh" "$LOCK_OUT"
121
91
  fi
122
-
123
- # 10. Pack and sanity-check the tarball's embedded version.
124
- cd "$REPO_PATH" || fail "pack" "cannot cd to $REPO_PATH"
125
- npm pack >/dev/null 2>&1 || fail "pack" "npm pack failed"
126
- TGZ="$REPO_PATH/$PKG-$TARGET_VERSION.tgz"
127
- [ -f "$TGZ" ] || fail "pack" "expected tarball $TGZ not produced"
128
- TGZ_VER="$(tar -xzOf "$TGZ" package/package.json \
129
- | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).version")" \
130
- || fail "pack-version-mismatch" "could not read version from tarball $TGZ"
131
- [ "$TGZ_VER" = "$TARGET_VERSION" ] \
132
- || fail "pack-version-mismatch" "tarball has $TGZ_VER, expected $TARGET_VERSION"
133
-
134
- # 11. Publish. "previously published versions" means a retried Publish
135
- # already landed this version (the merge lock guarantees no other task
136
- # picked it) — continue. Any other failure is fatal.
137
- if PUB_OUT="$(python3 "$NPM_PUBLISH_PY" "$TGZ" 2>&1)"; then
138
- echo "PUBLISHED=$TARGET_VERSION"
139
- else
140
- case "$PUB_OUT" in
141
- *"previously published versions"*)
142
- echo "PUBLISHED_ALREADY=$TARGET_VERSION"
143
- ;;
144
- *)
145
- rm -f "$TGZ"
146
- fail "publish" "$(printf '%s\n' "$PUB_OUT" | tail -5)"
147
- ;;
92
+ # STEP-5-END
93
+
94
+ if [ "$NO_LOCK_HELD" = "0" ]; then
95
+ # 6. Install/activate the immutable release.
96
+ REL_OUT="$(bash "${RELEASE_SCRIPT:?RELEASE_SCRIPT is required}" deploy "$REPO_PATH" 2>&1)" \
97
+ || fail "release-deploy" "$REL_OUT"
98
+ case "$REL_OUT" in
99
+ *INSTALLED*ACTIVATED*|*EXISTS*ACTIVATED*) ;;
100
+ *) fail "release-deploy" "unexpected output: $REL_OUT" ;;
148
101
  esac
149
- fi
150
- rm -f "$TGZ"
151
-
152
- # 12. Verify: ground truth is the registry, not any agent's summary.
153
- # The registry is eventually consistent: a publish followed by an
154
- # immediate read can observe the pre-publish version (canary 5a027278
155
- # hit a stale read replica ~6s after publish). A single stale read must
156
- # never park a landed publish, so this block retries with backoff and
157
- # client cache-busting instead of failing on the first mismatch.
158
- # STEP-12-ANCHOR: publish verification (retry-tolerant)
159
- VERIFY_ATTEMPTS="${VERIFY_ATTEMPTS:-12}"
160
- VERIFY_SLEEP_SECS="${VERIFY_SLEEP_SECS:-10}"
161
- REG=""
162
- for attempt in $(seq 1 "$VERIFY_ATTEMPTS"); do
163
- # --prefer-online busts npm's client-side packument cache; the retry
164
- # loop absorbs server-side replica lag. A non-zero exit is a miss,
165
- # not an immediate failure.
166
- REG="$(npm view "$PKG" version --prefer-online 2>/dev/null || true)"
167
- [ "$REG" = "$TARGET_VERSION" ] && break
168
- echo "PUBLISH_VERIFY_RETRY=$attempt registry=$REG"
169
- [ "$attempt" -lt "$VERIFY_ATTEMPTS" ] && sleep "$VERIFY_SLEEP_SECS"
170
- done
171
- [ "$REG" = "$TARGET_VERSION" ] \
172
- || fail "verify" "registry=$REG after $VERIFY_ATTEMPTS attempts"
173
- echo "PUBLISH_VERIFIED=$TARGET_VERSION"
174
- # STEP-12-END
102
+
103
+ # 7. Read the old version (audit only — TARGET_VERSION stays authoritative).
104
+ OLD="$(git -C "$REPO_PATH" show "HEAD:package.json" \
105
+ | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).version")" \
106
+ || fail "read-base" "could not read version from HEAD:package.json"
107
+ echo "PUBLISH_BASE=$OLD"
108
+
109
+ # 8. Write TARGET_VERSION into package.json (`version` field only) via
110
+ # byte-preserving text substitution. A JSON.parse/stringify round-trip is
111
+ # forbidden here: it expands unicode escapes (e.g. the \u2014 escape in
112
+ # the description field) into literal characters, producing a two-line
113
+ # diff that trips the preservation check below (canary 5a027278). The
114
+ # substitution touches exactly one line; a missed substitution yields an
115
+ # empty diff and fails closed at the numstat check.
116
+ # STEP-8-ANCHOR: version write (escape-preserving)
117
+ PKG_JSON="$REPO_PATH/package.json"
118
+ ESCAPED_VER="$(printf '%s' "$TARGET_VERSION" | sed 's/[&\\]/\\&/g')"
119
+ sed -i -E 's/^([[:space:]]*"version"[[:space:]]*:[[:space:]]*)"[^"]*"/\1"'"$ESCAPED_VER"'"/' "$PKG_JSON" \
120
+ || fail "version-write" "sed could not write $TARGET_VERSION into package.json"
121
+ # Read-only verification: parse package.json and confirm .version equals
122
+ # TARGET_VERSION. (Parse only no rewrite, so no clock/randomness.)
123
+ NEW_VER="$(PKG_JSON="$PKG_JSON" node -p "JSON.parse(require('fs').readFileSync(process.env.PKG_JSON,'utf8')).version")" \
124
+ || fail "version-write" "could not parse package.json after write"
125
+ [ "$NEW_VER" = "$TARGET_VERSION" ] \
126
+ || fail "version-write" "package.json version is $NEW_VER, expected $TARGET_VERSION"
127
+ echo "PUBLISH_DIFF:"
128
+ git -C "$REPO_PATH" diff -- package.json
129
+ ADD_DEL="$(git -C "$REPO_PATH" diff --numstat -- package.json | tr '\t' ' ')"
130
+ [ "$ADD_DEL" = "1 1 package.json" ] \
131
+ || fail "version-diff" "expected a single-line version change, got: $ADD_DEL"
132
+ # STEP-8-END
133
+
134
+ # 9. Commit only if changed (retried run leaves the commit in place).
135
+ if git -C "$REPO_PATH" diff --quiet -- package.json; then
136
+ echo "PUBLISH_COMMIT_EXISTS"
137
+ else
138
+ git -C "$REPO_PATH" add package.json \
139
+ || fail "commit" "git add package.json failed"
140
+ git -C "$REPO_PATH" commit -m "release: $PKG@$TARGET_VERSION ($TASK_ID)" >/dev/null \
141
+ || fail "commit" "git commit failed"
142
+ echo "PUBLISH_COMMIT=$(git -C "$REPO_PATH" rev-parse HEAD)"
143
+ fi
144
+
145
+ # 10. Pack and sanity-check the tarball's embedded version.
146
+ cd "$REPO_PATH" || fail "pack" "cannot cd to $REPO_PATH"
147
+ npm pack >/dev/null 2>&1 || fail "pack" "npm pack failed"
148
+ TGZ="$REPO_PATH/$PKG-$TARGET_VERSION.tgz"
149
+ [ -f "$TGZ" ] || fail "pack" "expected tarball $TGZ not produced"
150
+ TGZ_VER="$(tar -xzOf "$TGZ" package/package.json \
151
+ | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).version")" \
152
+ || fail "pack-version-mismatch" "could not read version from tarball $TGZ"
153
+ [ "$TGZ_VER" = "$TARGET_VERSION" ] \
154
+ || fail "pack-version-mismatch" "tarball has $TGZ_VER, expected $TARGET_VERSION"
155
+
156
+ # 11. Publish. "previously published versions" means a retried Publish
157
+ # already landed this version (the merge lock guarantees no other task
158
+ # picked it) — continue. Any other failure is fatal.
159
+ if PUB_OUT="$(python3 "$NPM_PUBLISH_PY" "$TGZ" 2>&1)"; then
160
+ echo "PUBLISHED=$TARGET_VERSION"
161
+ else
162
+ case "$PUB_OUT" in
163
+ *"previously published versions"*)
164
+ echo "PUBLISHED_ALREADY=$TARGET_VERSION"
165
+ ;;
166
+ *)
167
+ rm -f "$TGZ"
168
+ fail "publish" "$(printf '%s\n' "$PUB_OUT" | tail -5)"
169
+ ;;
170
+ esac
171
+ fi
172
+ rm -f "$TGZ"
173
+
174
+ # 12. Verify: ground truth is the registry, not any agent's summary.
175
+ # The registry is eventually consistent: a publish followed by an
176
+ # immediate read can observe the pre-publish version (canary 5a027278
177
+ # hit a stale read replica ~6s after publish). A single stale read must
178
+ # never park a landed publish, so this block retries with backoff and
179
+ # client cache-busting instead of failing on the first mismatch.
180
+ # STEP-12-ANCHOR: publish verification (retry-tolerant)
181
+ VERIFY_ATTEMPTS="${VERIFY_ATTEMPTS:-12}"
182
+ VERIFY_SLEEP_SECS="${VERIFY_SLEEP_SECS:-10}"
183
+ REG=""
184
+ for attempt in $(seq 1 "$VERIFY_ATTEMPTS"); do
185
+ # --prefer-online busts npm's client-side packument cache; the retry
186
+ # loop absorbs server-side replica lag. A non-zero exit is a miss,
187
+ # not an immediate failure.
188
+ REG="$(npm view "$PKG" version --prefer-online 2>/dev/null || true)"
189
+ [ "$REG" = "$TARGET_VERSION" ] && break
190
+ echo "PUBLISH_VERIFY_RETRY=$attempt registry=$REG"
191
+ [ "$attempt" -lt "$VERIFY_ATTEMPTS" ] && sleep "$VERIFY_SLEEP_SECS"
192
+ done
193
+ [ "$REG" = "$TARGET_VERSION" ] \
194
+ || fail "verify" "registry=$REG after $VERIFY_ATTEMPTS attempts"
195
+ echo "PUBLISH_VERIFIED=$TARGET_VERSION"
196
+ # STEP-12-END
197
+ fi # NO_LOCK_HELD=0: skip the mutation path when no lock is held
175
198
  fi
176
199
 
200
+ # 12b. Refresh the merge lock again — release-deploy, pack, publish, and the
201
+ # verify retry loop can together consume more than half the lease on a slow
202
+ # registry. The push and the post-deploy below must happen under our lock;
203
+ # if the refresh fails (expired lease, someone else holds it), stop — never
204
+ # push a version-bump commit or release a lock we no longer own.
205
+ LOCK_OUT2="$(CREW_REPO="$REPO_PATH" bash "${LIFECYCLE:?LIFECYCLE is required}" refresh-lock "$TASK_ID" 2>&1)" \
206
+ || fail "lock-refresh" "$LOCK_OUT2"
207
+
177
208
  # 13. Push the version-bump commit (idempotent: no-op if already pushed).
178
209
  PUSH_OUT="$(git -C "$REPO_PATH" push origin main 2>&1)" \
179
210
  || fail "push" "$PUSH_OUT"
@@ -187,5 +218,10 @@ case "$FIN_OUT" in
187
218
  *) fail "post-deploy" "missing DEPLOYED marker: $FIN_OUT" ;;
188
219
  esac
189
220
 
190
- # Final marker: the full publish path completed.
191
- echo "PUBLISH_COMPLETE=$TARGET_VERSION"
221
+ # Final marker. On the skip path (no lock held) there was nothing to
222
+ # publish: PUBLISH_SKIPPED=no-lock-held is the completion marker and
223
+ # PUBLISH_COMPLETE is deliberately not printed — the phase completed,
224
+ # the publish did not happen.
225
+ if [ "$NO_LOCK_HELD" = "0" ]; then
226
+ echo "PUBLISH_COMPLETE=$TARGET_VERSION"
227
+ fi
@@ -2,9 +2,12 @@
2
2
  # test-merge-lock.sh — regression tests for merge-lock.sh
3
3
  #
4
4
  # Self-contained: uses a temp CREW_REPO and drives lib/merge-lock.sh through
5
- # acquire/release/status. Tests the self-healing acquire: a lock held by a
6
- # dead PID is reclaimed automatically; a lock held by a live PID reports HELD.
7
- # Exits 0 only if every case passes.
5
+ # acquire/refresh/release/status. Tests the time-based lease model (bug
6
+ # 2fc8f52f): an unexpired lease blocks contenders even when no holder
7
+ # process is alive (PID liveness is never consulted); an expired lease is
8
+ # reclaimed with logging; refresh extends the lease; release is
9
+ # holder-only; status emits parseable holder/age/remaining fields; every
10
+ # op appends to the merge-lock audit log. Exits 0 only if every case passes.
8
11
 
9
12
  set -uo pipefail
10
13
 
@@ -20,49 +23,89 @@ TMPBASE="$(mktemp -d)"
20
23
  trap 'rm -rf "$TMPBASE"' EXIT
21
24
 
22
25
  export CREW_REPO="$TMPBASE/repo"
26
+ export CREW_HOME="$TMPBASE/home"
27
+ export MERGE_LOCK_LEASE_SECONDS=600
23
28
  mkdir -p "$CREW_REPO/.worktrees"
24
29
 
25
- # --- Case 1: acquire on unlocked succeeds ---
26
- # Use $$ (this shell's PID) which is definitely alive.
27
- out=$("$LOCK" acquire task-1 $$ 2>&1); code=$?
28
- [ "$code" -eq 0 ] && [[ "$out" == *"ACQUIRED by task-1"* ]] && ok "1: acquire on unlocked succeeds" || no "1: exit $code, out: $out"
30
+ # --- Case 1: acquire on unlocked succeeds (opaque holder, not a PID) ---
31
+ out=$("$LOCK" acquire task-1 "task-1/run-aaa" 2>&1); code=$?
32
+ [ "$code" -eq 0 ] && [[ "$out" == *"ACQUIRED by task-1"* ]] && [[ "$out" == *"task-1/run-aaa"* ]] \
33
+ && ok "1: acquire on unlocked succeeds with opaque holder" || no "1: exit $code, out: $out"
29
34
 
30
- # --- Case 2: second acquire with live PID reports HELD ---
31
- out=$("$LOCK" acquire task-2 99999 2>&1); code=$?
32
- [ "$code" -eq 1 ] && [[ "$out" == *"HELD by task-1"* ]] && ok "2: acquire with live holder PID reports HELD" || no "2: exit $code, out: $out"
35
+ # --- Case 2: contender blocked while lease unexpired (no PID involved) ---
36
+ out=$("$LOCK" acquire task-2 "task-2/run-bbb" 2>&1); code=$?
37
+ [ "$code" -eq 1 ] && [[ "$out" == *"HELD by task-1"* ]] \
38
+ && ok "2: unexpired lease blocks contender" || no "2: exit $code, out: $out"
33
39
 
34
- # --- Case 3: release by owner succeeds ---
35
- out=$("$LOCK" release task-1 2>&1); code=$?
36
- [ "$code" -eq 0 ] && [[ "$out" == *"RELEASED by task-1"* ]] && ok "3: release by owner succeeds" || no "3: exit $code, out: $out"
37
-
38
- # --- Case 4: status reports UNLOCKED after release ---
40
+ # --- Case 3: status emits parseable holder/age/remaining fields ---
39
41
  out=$("$LOCK" status 2>&1); code=$?
40
- [ "$code" -eq 0 ] && [[ "$out" == "UNLOCKED" ]] && ok "4: status UNLOCKED after release" || no "4: exit $code, out: $out"
42
+ [ "$code" -eq 0 ] \
43
+ && [[ "$out" == *"locked=true"* ]] \
44
+ && [[ "$out" == *"task_id=task-1"* ]] \
45
+ && [[ "$out" == *"holder=task-1/run-aaa"* ]] \
46
+ && [[ "$out" == *"age_seconds="* ]] \
47
+ && [[ "$out" == *"remaining_seconds="* ]] \
48
+ && ok "3: status has holder/age/remaining fields" || no "3: exit $code, out: $out"
49
+
50
+ # --- Case 4: refresh extends the lease (holder-only) ---
51
+ # Sleep long enough that integer-second lease math must show decay; a
52
+ # refresh then resets the clock and the remaining lease grows again.
53
+ sleep 3
54
+ just_before=$("$LOCK" status | sed -n 's/^remaining_seconds=//p')
55
+ "$LOCK" refresh task-1 >/dev/null 2>&1; code=$?
56
+ just_after=$("$LOCK" status | sed -n 's/^remaining_seconds=//p')
57
+ [ "$code" -eq 0 ] && [ "$just_after" -gt "$just_before" ] \
58
+ && ok "4: refresh extends the lease" || no "4: code $code, before $just_before, after $just_after"
59
+
60
+ # --- Case 5: refresh by non-holder fails ---
61
+ out=$("$LOCK" refresh task-2 2>&1); code=$?
62
+ [ "$code" -eq 1 ] && [[ "$out" == *"held by task-1"* ]] \
63
+ && ok "5: refresh by non-holder fails" || no "5: exit $code, out: $out"
41
64
 
42
- # --- Case 5: stale lock (dead PID) is reclaimed on acquire ---
43
- # Write a lock file with a PID that is certainly dead (99999999).
44
- echo "stale-task 2026-09-10T06:21:15Z 99999999" > "$CREW_REPO/.worktrees/.merge-lock"
45
- out=$("$LOCK" acquire task-3 22222 2>&1); code=$?
46
- [ "$code" -eq 0 ] && [[ "$out" == *"reclaimed stale lock from stale-task"* ]] && ok "5: stale lock (dead PID) reclaimed on acquire" || no "5: exit $code, out: $out"
65
+ # --- Case 6: release by non-holder fails ---
66
+ out=$("$LOCK" release task-2 2>&1); code=$?
67
+ [ "$code" -eq 1 ] && [[ "$out" == *"held by task-1"* ]] \
68
+ && ok "6: release by non-holder fails" || no "6: exit $code, out: $out"
47
69
 
48
- # --- Case 6: after reclaim, the new holder owns the lock ---
70
+ # --- Case 7: release by holder succeeds and removes the lock ---
71
+ out=$("$LOCK" release task-1 2>&1); code=$?
72
+ [ "$code" -eq 0 ] && [[ "$out" == *"RELEASED by task-1"* ]] && [ ! -f "$CREW_REPO/.worktrees/.merge-lock" ] \
73
+ && ok "7: release by holder removes lock file" || no "7: exit $code, out: $out"
74
+
75
+ # --- Case 8: status reports UNLOCKED after release ---
49
76
  out=$("$LOCK" status 2>&1); code=$?
50
- [ "$code" -eq 0 ] && [[ "$out" == *"LOCKED by task-3"* ]] && ok "6: reclaimed lock owned by new task" || no "6: exit $code, out: $out"
77
+ [ "$code" -eq 0 ] && [ "$out" = "UNLOCKED" ] \
78
+ && ok "8: status UNLOCKED after release" || no "8: exit $code, out: $out"
79
+
80
+ # --- Case 9: expired lease is reclaimed (logged) by a contender ---
81
+ # Write a lock file whose lease expired long ago (epoch 2020, lease 600s).
82
+ printf 'task_id=old-task\nholder=old-task/run-old\nacquired_at=1577836800\nlease_seconds=600\n' \
83
+ > "$CREW_REPO/.worktrees/.merge-lock"
84
+ out=$("$LOCK" acquire task-3 "task-3/run-ccc" 2>&1); code=$?
85
+ [ "$code" -eq 0 ] && [[ "$out" == *"ACQUIRED by task-3"* ]] && [[ "$out" == *"reclaimed expired lease from old-task"* ]] \
86
+ && ok "9: expired lease reclaimed by contender" || no "9: exit $code, out: $out"
87
+ [ -f "$TMPBASE/home/.merge-lock.log" ] && grep -q "op=acquire.*result=STALE-BREAK.*previous_task=old-task" "$TMPBASE/home/.merge-lock.log" \
88
+ && ok "9b: reclaim logged to the audit log" || no "9b: no STALE-BREAK line in audit log"
51
89
 
52
- # --- Case 7: release by non-owner fails ---
53
- out=$("$LOCK" release wrong-task 2>&1); code=$?
54
- [ "$code" -eq 1 ] && [[ "$out" == *"ERROR: lock held by task-3"* ]] && ok "7: release by non-owner fails" || no "7: exit $code, out: $out"
90
+ # --- Case 10: malformed lock file is treated as expired and reclaimed ---
91
+ printf 'garbage without any key=value\n' > "$CREW_REPO/.worktrees/.merge-lock"
92
+ out=$("$LOCK" acquire task-4 "task-4/run-ddd" 2>&1); code=$?
93
+ [ "$code" -eq 0 ] && [[ "$out" == *"ACQUIRED by task-4"* ]] \
94
+ && ok "10: malformed lock reclaimed" || no "10: exit $code, out: $out"
55
95
 
56
- # --- Case 8: release by owner cleans up ---
57
- out=$("$LOCK" release task-3 2>&1); code=$?
58
- [ "$code" -eq 0 ] && [ ! -f "$CREW_REPO/.worktrees/.merge-lock" ] && ok "8: release removes lock file" || no "8: exit $code, out: $out"
96
+ # --- Case 11: every op appends to the audit log ---
97
+ lines=$(wc -l < "$TMPBASE/home/.merge-lock.log")
98
+ [ "$lines" -ge 8 ] && ok "11: audit log has $lines lines" || no "11: audit log has only $lines lines"
59
99
 
60
- # --- Case 9: lock with missing PID field is treated as stale ---
61
- # A lock file with "-" as PID (or empty) should be reclaimable.
62
- echo "old-task 2026-09-10T06:21:15Z -" > "$CREW_REPO/.worktrees/.merge-lock"
63
- out=$("$LOCK" acquire task-4 33333 2>&1); code=$?
64
- [ "$code" -eq 0 ] && [[ "$out" == *"reclaimed stale lock from old-task"* ]] && ok "9: lock with '-' PID reclaimed as stale" || no "9: exit $code, out: $out"
100
+ # --- Case 12: concurrent acquirers serialize exactly one wins ---
65
101
  "$LOCK" release task-4 >/dev/null 2>&1
102
+ for n in $(seq 1 20); do
103
+ ( "$LOCK" acquire "racer-$n" "racer-$n/run" >/dev/null 2>&1 ) &
104
+ done
105
+ wait
106
+ holder_count=$("$LOCK" status | grep -c '^task_id=racer-' || true)
107
+ [ "$holder_count" -eq 1 ] && ok "12: exactly one concurrent acquirer holds the lock" || no "12: holder_count=$holder_count"
108
+ grep -q "result=ACQUIRED" "$TMPBASE/home/.merge-lock.log" && ok "12b: at least one ACQUIRED logged" || no "12b: no ACQUIRED in audit log"
66
109
 
67
110
  echo ""
68
111
  echo "merge-lock: $pass passed, $fail failed"
@@ -22,6 +22,9 @@ no() { echo "FAIL: $1"; fail=$((fail + 1)); }
22
22
  TMPBASE="$(mktemp -d)"
23
23
  trap 'rm -rf "$TMPBASE"' EXIT
24
24
 
25
+ # Keep merge-lock.sh's audit log out of the real $CREW_HOME during tests.
26
+ export CREW_HOME="$TMPBASE/home"
27
+
25
28
  # new_fixture <name> — fresh git repo, one commit on main; exports CREW_REPO.
26
29
  new_fixture() {
27
30
  local dir="$TMPBASE/$1"
@@ -90,21 +93,32 @@ echo "$out" | grep -q "→ FAILED" && ok "4: FAILED line printed (no false 'remo
90
93
  [ -d .worktrees/T4 ] && ok "4: failed worktree left in place" || no "4: worktree gone despite failed removal"
91
94
  git worktree unlock .worktrees/T4 2>/dev/null || true
92
95
 
93
- # --- Case 5: lock path ---
96
+ # --- Case 5: lock path (lease-based; bug 2fc8f52f) ---
94
97
  new_fixture case5
95
98
  mkdir -p .worktrees
96
- printf 'T5 2020-01-01T00:00:00Z 999999\n' > .worktrees/.merge-lock
99
+ # 5a: expired lease, no running session → released (logged to the audit log).
100
+ printf 'task_id=T5\nholder=T5/run-old\nacquired_at=1577836800\nlease_seconds=600\n' > .worktrees/.merge-lock
97
101
  out=$(CREW_ACTIVE_TASKS="" "$SWEEP" clean 2>&1); code=$?
98
- [ ! -f .worktrees/.merge-lock ] && ok "5a: stale lock released" || no "5a: stale lock file still present"
99
- echo "$out" | grep -q "released (dead PID)" && ok "5a: release reported" || no "5a: no release line in output"
102
+ [ ! -f .worktrees/.merge-lock ] && ok "5a: expired-lease lock released" || no "5a: lock file still present"
103
+ echo "$out" | grep -q "STALE_LOCK" && ok "5a: STALE_LOCK line printed" || no "5a: no STALE_LOCK line in output"
104
+ echo "$out" | grep -q "released (lease expired)" && ok "5a: release reported" || no "5a: no release line in output"
100
105
  [ "$code" -eq 0 ] && ok "5a: exit 0" || no "5a: exit $code, want 0"
101
106
 
102
- printf '%s 2020-01-01T00:00:00Z 999999\n' "$UUID_LOCK" > .worktrees/.merge-lock
107
+ # 5b: valid lease held by an active run → untouched regardless of lease age.
108
+ now=$(date +%s)
109
+ printf 'task_id=%s\nholder=%s/run-live\nacquired_at=%s\nlease_seconds=600\n' "$UUID_LOCK" "$UUID_LOCK" "$now" > .worktrees/.merge-lock
103
110
  out=$(CREW_ACTIVE_TASKS="$UUID_LOCK" "$SWEEP" clean 2>&1); code=$?
104
111
  [ -f .worktrees/.merge-lock ] && ok "5b: active holder's lock not released" || no "5b: active holder's lock was released"
105
112
  echo "$out" | grep -q "ACTIVE_LOCK" && ok "5b: ACTIVE_LOCK line printed" || no "5b: no ACTIVE_LOCK line in output"
106
113
  [ "$code" -eq 0 ] && ok "5b: exit 0" || no "5b: exit $code, want 0"
107
114
 
115
+ # 5c: valid lease, no running session → held, NOT released (lease not expired).
116
+ printf 'task_id=T6\nholder=T6/run-live\nacquired_at=%s\nlease_seconds=600\n' "$now" > .worktrees/.merge-lock
117
+ out=$(CREW_ACTIVE_TASKS="" "$SWEEP" clean 2>&1); code=$?
118
+ [ -f .worktrees/.merge-lock ] && ok "5c: unexpired-lease lock not released" || no "5c: unexpired-lease lock was released"
119
+ echo "$out" | grep -q "HELD_LOCK" && ok "5c: HELD_LOCK line printed" || no "5c: no HELD_LOCK line in output"
120
+ [ "$code" -eq 0 ] && ok "5c: exit 0" || no "5c: exit $code, want 0"
121
+
108
122
  # --- Case 6: report mode without active data ---
109
123
  new_fixture case6
110
124
  merged_worktree T7