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 +5 -4
- package/lib/merge-lock.sh +149 -68
- package/lib/orphan-sweep.sh +38 -34
- package/lib/publish-npm.sh +141 -105
- package/lib/test-merge-lock.sh +77 -34
- package/lib/test-orphan-sweep.sh +19 -5
- package/lib/test-publish-skip.sh +192 -0
- package/lib/worktree-lifecycle.sh +46 -8
- package/package.json +1 -1
- package/workflows/bugfix.js +144 -36
- package/workflows/chore.js +141 -35
- package/workflows/standard.js +144 -36
package/lib/publish-npm.sh
CHANGED
|
@@ -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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
#
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
#
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
191
|
-
|
|
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
|
package/lib/test-merge-lock.sh
CHANGED
|
@@ -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
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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:
|
|
31
|
-
out=$("$LOCK" acquire task-2
|
|
32
|
-
[ "$code" -eq 1 ] && [[ "$out" == *"HELD by task-1"* ]]
|
|
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:
|
|
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 ]
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
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 ] && [
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
57
|
-
|
|
58
|
-
[ "$
|
|
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
|
|
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"
|
package/lib/test-orphan-sweep.sh
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
99
|
-
echo "$out" | grep -q "
|
|
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
|
-
|
|
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
|