muse-crew 0.6.4 → 0.6.6
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 +2 -2
- package/lib/orphan-sweep.sh +59 -6
- package/lib/publish-npm.sh +23 -0
- package/lib/test-orphan-sweep.sh +88 -1
- package/lib/test-publish-skip.sh +3 -2
- package/lib/test-publish-stranded.sh +154 -0
- package/lib/worktree-lifecycle.sh +1 -1
- package/package.json +1 -1
- package/workflows/crew-dispatch.js +14 -7
package/lib/AGENTS.md
CHANGED
|
@@ -5,12 +5,12 @@ Shell scripts for the crew's infrastructure. Called by workflow scripts, cron, a
|
|
|
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
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/verify-merge/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
|
|
8
|
+
- `worktree-lifecycle.sh` — the worktree lifecycle seam: prepare/cleanup/inspect/integrate/verify-merge/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 key=value: locked=true, task_id, holder, acquired_at, lease_seconds, age_seconds, remaining_seconds — 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
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
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
|
+
- `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); reaps a lifecycle pin only after N consecutive clean-mode observations (default 3, `ORPHAN_SWEEP_REAP_AFTER`) with the task absent from the active-run list — a single negative observation only plants a tombstone, so a daemon-restart window can't reap a live task's pin
|
|
14
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.
|
|
15
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.
|
|
16
16
|
- `test-orphan-sweep.sh` — regression tests for orphan-sweep.sh (active-run guard, verified removal, fail-closed)
|
package/lib/orphan-sweep.sh
CHANGED
|
@@ -29,6 +29,15 @@
|
|
|
29
29
|
# task has no running session is safe to remove: any new run re-pins
|
|
30
30
|
# idempotently (mkdir -p + cp) before use.
|
|
31
31
|
#
|
|
32
|
+
# Restart-fragility guard (bug a8bf78ca): pin reaping requires REAP_AFTER
|
|
33
|
+
# (default 3, override via ORPHAN_SWEEP_REAP_AFTER) consecutive clean-mode
|
|
34
|
+
# observations with valid active-run data showing the task absent. A single
|
|
35
|
+
# observation never reaps a pin — a daemon restart can make a live task's
|
|
36
|
+
# session read as not-running for one observation window. A first absence
|
|
37
|
+
# only plants a tombstone marker ($CREW_HOME/.pins/.sweep-suspects/<task>);
|
|
38
|
+
# a task present in the active set is never touched and clears its marker.
|
|
39
|
+
# Report mode and BLOCKED clean (no active-run data) never touch markers.
|
|
40
|
+
#
|
|
32
41
|
# Fail closed: clean mode without either input refuses to remove anything
|
|
33
42
|
# and exits 2. Report mode without either input still runs read-only but
|
|
34
43
|
# prints a banner noting the ACTIVE checks were skipped.
|
|
@@ -191,37 +200,81 @@ if [ -d "$WORKTREE_DIR" ]; then
|
|
|
191
200
|
done
|
|
192
201
|
fi
|
|
193
202
|
|
|
194
|
-
# --- Reap stale lifecycle pins ---
|
|
195
|
-
#
|
|
196
|
-
#
|
|
203
|
+
# --- Reap stale lifecycle pins (restart-fragility guard; bug a8bf78ca) ---
|
|
204
|
+
# A pin whose task is absent from the active set is reaped only after
|
|
205
|
+
# REAP_AFTER consecutive clean-mode observations with valid active-run
|
|
206
|
+
# data still show it absent. Counters only — no wall clock.
|
|
197
207
|
PINS_DIR="${CREW_HOME:-$HOME/workspace/.jarvis}/.pins"
|
|
208
|
+
SUSPECTS_DIR="$PINS_DIR/.sweep-suspects"
|
|
209
|
+
REAP_AFTER="${ORPHAN_SWEEP_REAP_AFTER:-3}"
|
|
210
|
+
if [ "$cmd" = "clean" ]; then
|
|
211
|
+
if ! [[ "$REAP_AFTER" =~ ^[1-9][0-9]*$ ]]; then
|
|
212
|
+
echo "BLOCKED: ORPHAN_SWEEP_REAP_AFTER='$REAP_AFTER' is not a positive integer — refusing to clean"
|
|
213
|
+
exit 2
|
|
214
|
+
fi
|
|
215
|
+
fi
|
|
198
216
|
if [ -d "$PINS_DIR" ]; then
|
|
199
217
|
for pin in "$PINS_DIR"/*/; do
|
|
200
218
|
[ -d "$pin" ] || continue
|
|
201
219
|
task_id=$(basename "$pin")
|
|
202
220
|
|
|
203
|
-
# Skip hidden dirs
|
|
221
|
+
# Skip hidden dirs (the .sweep-suspects dir lives under PINS_DIR)
|
|
204
222
|
[[ "$task_id" == .* ]] && continue
|
|
205
223
|
|
|
206
224
|
found=1
|
|
207
225
|
|
|
208
|
-
# Active runs are never touched.
|
|
226
|
+
# Active runs are never touched; a pending tombstone is stale.
|
|
209
227
|
if task_is_active "$task_id"; then
|
|
228
|
+
if [ -f "$SUSPECTS_DIR/$task_id" ]; then
|
|
229
|
+
rm -f "$SUSPECTS_DIR/$task_id"
|
|
230
|
+
fi
|
|
210
231
|
echo "ACTIVE_PIN: $task_id — dashboard shows a running session, kept"
|
|
211
232
|
continue
|
|
212
233
|
fi
|
|
213
234
|
|
|
214
|
-
|
|
235
|
+
# Clean mode: count consecutive negative observations; reap only after
|
|
236
|
+
# REAP_AFTER of them. Report mode stays read-only and never touches
|
|
237
|
+
# markers — only clean-mode observations count.
|
|
215
238
|
if [ "$cmd" = "clean" ]; then
|
|
239
|
+
count=0
|
|
240
|
+
marker="$SUSPECTS_DIR/$task_id"
|
|
241
|
+
if [ -f "$marker" ]; then
|
|
242
|
+
count=$(cat "$marker" 2>/dev/null || echo 0)
|
|
243
|
+
[[ "$count" =~ ^[0-9]+$ ]] || count=0
|
|
244
|
+
fi
|
|
245
|
+
count=$((count + 1))
|
|
246
|
+
if [ "$count" -lt "$REAP_AFTER" ]; then
|
|
247
|
+
mkdir -p "$SUSPECTS_DIR"
|
|
248
|
+
printf '%s\n' "$count" > "$marker"
|
|
249
|
+
echo "SUSPECT_PIN: $task_id — no running session (observation $count/$REAP_AFTER), kept for now"
|
|
250
|
+
continue
|
|
251
|
+
fi
|
|
252
|
+
echo "STALE_PIN: $task_id — no running session for $REAP_AFTER consecutive observations, removing"
|
|
216
253
|
rm -rf "$pin"
|
|
254
|
+
rm -f "$marker"
|
|
217
255
|
if [ ! -d "$pin" ]; then
|
|
218
256
|
echo " → removed"
|
|
219
257
|
else
|
|
220
258
|
echo " → FAILED: pin still present after rm, left in place"
|
|
221
259
|
any_failed=1
|
|
222
260
|
fi
|
|
261
|
+
continue
|
|
223
262
|
fi
|
|
263
|
+
|
|
264
|
+
echo "STALE_PIN: $task_id — no running session, safe to remove"
|
|
224
265
|
done
|
|
266
|
+
|
|
267
|
+
# Prune tombstones whose pin dir no longer exists (e.g. a run cleaned up
|
|
268
|
+
# its own pin). Deleting state is a clean-mode action only.
|
|
269
|
+
if [ "$cmd" = "clean" ] && [ -d "$SUSPECTS_DIR" ]; then
|
|
270
|
+
for marker in "$SUSPECTS_DIR"/*; do
|
|
271
|
+
[ -f "$marker" ] || continue
|
|
272
|
+
marker_task=$(basename "$marker")
|
|
273
|
+
if [ ! -d "$PINS_DIR/$marker_task" ]; then
|
|
274
|
+
rm -f "$marker"
|
|
275
|
+
fi
|
|
276
|
+
done
|
|
277
|
+
fi
|
|
225
278
|
fi
|
|
226
279
|
|
|
227
280
|
if [ "$found" -eq 0 ]; then
|
package/lib/publish-npm.sh
CHANGED
|
@@ -115,6 +115,28 @@ if [ "$ALREADY_PUBLISHED" = "0" ]; then
|
|
|
115
115
|
# empty diff and fails closed at the numstat check.
|
|
116
116
|
# STEP-8-ANCHOR: version write (escape-preserving)
|
|
117
117
|
PKG_JSON="$REPO_PATH/package.json"
|
|
118
|
+
# STRANDED-ANCHOR: resume after a killed publish (release commit made,
|
|
119
|
+
# registry never updated). If the working tree's package.json already
|
|
120
|
+
# carries TARGET_VERSION and HEAD is the script's own release commit for
|
|
121
|
+
# it, the version write is a no-op by design — skip it and let step 9
|
|
122
|
+
# confirm the commit. If HEAD is NOT the release commit, fail closed:
|
|
123
|
+
# a foreign version bump must never be papered over by a publish.
|
|
124
|
+
PRE_VER="$(PKG_JSON="$PKG_JSON" node -p "JSON.parse(require('fs').readFileSync(process.env.PKG_JSON,'utf8')).version")" \
|
|
125
|
+
|| fail "version-write" "could not parse package.json before write"
|
|
126
|
+
STRANDED=0
|
|
127
|
+
if [ "$PRE_VER" = "$TARGET_VERSION" ]; then
|
|
128
|
+
case "$HEAD_MSG" in
|
|
129
|
+
"release: $PKG@$TARGET_VERSION"*)
|
|
130
|
+
STRANDED=1
|
|
131
|
+
echo "PUBLISH_STRANDED_RESUME=1"
|
|
132
|
+
;;
|
|
133
|
+
*)
|
|
134
|
+
fail "version-diff" "package.json already at $TARGET_VERSION but HEAD is not its release commit: $HEAD_MSG"
|
|
135
|
+
;;
|
|
136
|
+
esac
|
|
137
|
+
fi
|
|
138
|
+
# STRANDED-END
|
|
139
|
+
if [ "$STRANDED" = "0" ]; then
|
|
118
140
|
ESCAPED_VER="$(printf '%s' "$TARGET_VERSION" | sed 's/[&\\]/\\&/g')"
|
|
119
141
|
sed -i -E 's/^([[:space:]]*"version"[[:space:]]*:[[:space:]]*)"[^"]*"/\1"'"$ESCAPED_VER"'"/' "$PKG_JSON" \
|
|
120
142
|
|| fail "version-write" "sed could not write $TARGET_VERSION into package.json"
|
|
@@ -129,6 +151,7 @@ if [ "$ALREADY_PUBLISHED" = "0" ]; then
|
|
|
129
151
|
ADD_DEL="$(git -C "$REPO_PATH" diff --numstat -- package.json | tr '\t' ' ')"
|
|
130
152
|
[ "$ADD_DEL" = "1 1 package.json" ] \
|
|
131
153
|
|| fail "version-diff" "expected a single-line version change, got: $ADD_DEL"
|
|
154
|
+
fi
|
|
132
155
|
# STEP-8-END
|
|
133
156
|
|
|
134
157
|
# 9. Commit only if changed (retried run leaves the commit in place).
|
package/lib/test-orphan-sweep.sh
CHANGED
|
@@ -129,12 +129,19 @@ echo "$out" | grep -q "NOTE: no active-run data provided; ACTIVE checks skipped"
|
|
|
129
129
|
echo "$out" | grep -q "MERGED: T7" && ok "6: MERGED line still listed" || no "6: MERGED line missing from report"
|
|
130
130
|
|
|
131
131
|
# --- Case 7: lifecycle pins — active kept, stale reaped ---
|
|
132
|
+
# A stale pin now needs ORPHAN_SWEEP_REAP_AFTER consecutive observations;
|
|
133
|
+
# set 1 here to exercise the immediate-reap path directly.
|
|
132
134
|
new_fixture case7
|
|
133
135
|
export CREW_HOME="$TMPBASE/case7home"
|
|
134
136
|
mkdir -p "$CREW_HOME/.pins/$UUID_PIN" "$CREW_HOME/.pins/STALEPIN"
|
|
135
137
|
echo x > "$CREW_HOME/.pins/$UUID_PIN/worktree-lifecycle.sh"
|
|
136
138
|
echo x > "$CREW_HOME/.pins/STALEPIN/worktree-lifecycle.sh"
|
|
137
|
-
out=$(CREW_ACTIVE_TASKS="$UUID_PIN" "$SWEEP" clean 2>&1); code=$?
|
|
139
|
+
out=$(CREW_ACTIVE_TASKS="$UUID_PIN" ORPHAN_SWEEP_REAP_AFTER=1 "$SWEEP" clean 2>&1); code=$?
|
|
140
|
+
[ "$code" -eq 0 ] && ok "7: clean exits 0 with pins present" || no "7: exit $code, want 0"
|
|
141
|
+
[ -d "$CREW_HOME/.pins/$UUID_PIN" ] && ok "7: active task's pin kept" || no "7: active task's pin removed"
|
|
142
|
+
[ ! -d "$CREW_HOME/.pins/STALEPIN" ] && ok "7: stale pin removed" || no "7: stale pin still present"
|
|
143
|
+
echo "$out" | grep -q "ACTIVE_PIN: $UUID_PIN" && ok "7: ACTIVE_PIN line printed" || no "7: no ACTIVE_PIN line in output"
|
|
144
|
+
echo "$out" | grep -q "STALE_PIN: STALEPIN" && ok "7: STALE_PIN line printed" || no "7: no STALE_PIN line in output"
|
|
138
145
|
[ "$code" -eq 0 ] && ok "7: clean exits 0 with pins present" || no "7: exit $code, want 0"
|
|
139
146
|
[ -d "$CREW_HOME/.pins/$UUID_PIN" ] && ok "7: active task's pin kept" || no "7: active task's pin removed"
|
|
140
147
|
[ ! -d "$CREW_HOME/.pins/STALEPIN" ] && ok "7: stale pin removed" || no "7: stale pin still present"
|
|
@@ -184,6 +191,86 @@ echo "$out" | grep -q "$SHORT_ACTIVE" && ok "10: BLOCKED line names the bad entr
|
|
|
184
191
|
git show-ref --verify --quiet "refs/heads/task/$UUID_ACTIVE" && ok "10: branch preserved" || no "10: branch deleted despite BLOCKED"
|
|
185
192
|
[ -d "$CREW_HOME/.pins/$UUID_ACTIVE" ] && ok "10: pin preserved" || no "10: pin removed despite BLOCKED"
|
|
186
193
|
|
|
194
|
+
# --- Case 11: one bad observation doesn't reap (the incident's exact case) ---
|
|
195
|
+
# REAP_AFTER=2: first clean with the task absent only plants a tombstone;
|
|
196
|
+
# the second consecutive absence reaps the pin and clears the marker.
|
|
197
|
+
UUID_TOMB="3472bf36-1549-44bd-bc06-e0c80aef0007"
|
|
198
|
+
new_fixture case11
|
|
199
|
+
export CREW_HOME="$TMPBASE/case11home"
|
|
200
|
+
mkdir -p "$CREW_HOME/.pins/$UUID_TOMB"
|
|
201
|
+
echo x > "$CREW_HOME/.pins/$UUID_TOMB/worktree-lifecycle.sh"
|
|
202
|
+
out=$(CREW_ACTIVE_TASKS="" ORPHAN_SWEEP_REAP_AFTER=2 "$SWEEP" clean 2>&1); code=$?
|
|
203
|
+
[ "$code" -eq 0 ] && ok "11: first clean exits 0" || no "11: first clean exit $code, want 0"
|
|
204
|
+
[ -d "$CREW_HOME/.pins/$UUID_TOMB" ] && ok "11: pin intact after one bad observation" || no "11: pin reaped after ONE observation — the bug"
|
|
205
|
+
echo "$out" | grep -q "SUSPECT_PIN: $UUID_TOMB — no running session (observation 1/2)" && ok "11: SUSPECT_PIN 1/2 line printed" || no "11: no SUSPECT_PIN 1/2 line in output"
|
|
206
|
+
[ "$(cat "$CREW_HOME/.pins/.sweep-suspects/$UUID_TOMB" 2>/dev/null)" = "1" ] && ok "11: tombstone marker holds count 1" || no "11: tombstone marker missing or wrong"
|
|
207
|
+
out=$(CREW_ACTIVE_TASKS="" ORPHAN_SWEEP_REAP_AFTER=2 "$SWEEP" clean 2>&1); code=$?
|
|
208
|
+
[ "$code" -eq 0 ] && ok "11: second clean exits 0" || no "11: second clean exit $code, want 0"
|
|
209
|
+
[ ! -d "$CREW_HOME/.pins/$UUID_TOMB" ] && ok "11: pin reaped after second consecutive absence" || no "11: pin still present after two observations"
|
|
210
|
+
echo "$out" | grep -q "no running session for 2 consecutive observations" && ok "11: reap line cites consecutive observations" || no "11: no consecutive-observations line in output"
|
|
211
|
+
[ ! -f "$CREW_HOME/.pins/.sweep-suspects/$UUID_TOMB" ] && ok "11: marker removed after reap" || no "11: marker left behind after reap"
|
|
212
|
+
|
|
213
|
+
# --- Case 12: recovery clears the tombstone ---
|
|
214
|
+
new_fixture case12
|
|
215
|
+
export CREW_HOME="$TMPBASE/case12home"
|
|
216
|
+
mkdir -p "$CREW_HOME/.pins/$UUID_TOMB"
|
|
217
|
+
echo x > "$CREW_HOME/.pins/$UUID_TOMB/worktree-lifecycle.sh"
|
|
218
|
+
out=$(CREW_ACTIVE_TASKS="" ORPHAN_SWEEP_REAP_AFTER=2 "$SWEEP" clean 2>&1); code=$?
|
|
219
|
+
[ "$(cat "$CREW_HOME/.pins/.sweep-suspects/$UUID_TOMB" 2>/dev/null)" = "1" ] && ok "12: tombstone planted on first absence" || no "12: no tombstone after first absence"
|
|
220
|
+
out=$(CREW_ACTIVE_TASKS="$UUID_TOMB" ORPHAN_SWEEP_REAP_AFTER=2 "$SWEEP" clean 2>&1); code=$?
|
|
221
|
+
[ "$code" -eq 0 ] && ok "12: recovery clean exits 0" || no "12: recovery clean exit $code, want 0"
|
|
222
|
+
[ -d "$CREW_HOME/.pins/$UUID_TOMB" ] && ok "12: pin intact after recovery" || no "12: pin removed despite task being active"
|
|
223
|
+
[ ! -f "$CREW_HOME/.pins/.sweep-suspects/$UUID_TOMB" ] && ok "12: tombstone cleared on recovery" || no "12: tombstone left behind after recovery"
|
|
224
|
+
echo "$out" | grep -q "ACTIVE_PIN: $UUID_TOMB" && ok "12: ACTIVE_PIN line printed" || no "12: no ACTIVE_PIN line in output"
|
|
225
|
+
|
|
226
|
+
# --- Case 13: report mode never counts as an observation ---
|
|
227
|
+
new_fixture case13
|
|
228
|
+
export CREW_HOME="$TMPBASE/case13home"
|
|
229
|
+
mkdir -p "$CREW_HOME/.pins/$UUID_TOMB"
|
|
230
|
+
echo x > "$CREW_HOME/.pins/$UUID_TOMB/worktree-lifecycle.sh"
|
|
231
|
+
out=$(CREW_ACTIVE_TASKS="" ORPHAN_SWEEP_REAP_AFTER=2 "$SWEEP" clean 2>&1); code=$?
|
|
232
|
+
[ "$(cat "$CREW_HOME/.pins/.sweep-suspects/$UUID_TOMB" 2>/dev/null)" = "1" ] && ok "13: tombstone planted by clean" || no "13: no tombstone after clean"
|
|
233
|
+
out=$(CREW_ACTIVE_TASKS="" ORPHAN_SWEEP_REAP_AFTER=2 "$SWEEP" report 2>&1); code=$?
|
|
234
|
+
[ "$code" -eq 0 ] && ok "13: report exits 0" || no "13: report exit $code, want 0"
|
|
235
|
+
[ "$(cat "$CREW_HOME/.pins/.sweep-suspects/$UUID_TOMB" 2>/dev/null)" = "1" ] && ok "13: marker still 1 after report" || no "13: report mode counted as an observation"
|
|
236
|
+
[ -d "$CREW_HOME/.pins/$UUID_TOMB" ] && ok "13: pin intact after report" || no "13: report mode removed a pin"
|
|
237
|
+
out=$(CREW_ACTIVE_TASKS="" ORPHAN_SWEEP_REAP_AFTER=2 "$SWEEP" clean 2>&1); code=$?
|
|
238
|
+
[ ! -d "$CREW_HOME/.pins/$UUID_TOMB" ] && ok "13: report did not advance the count (second clean reaps at 2/2)" || no "13: pin not reaped after two clean observations"
|
|
239
|
+
|
|
240
|
+
# --- Case 14: BLOCKED (no active data) never counts as an observation ---
|
|
241
|
+
new_fixture case14
|
|
242
|
+
export CREW_HOME="$TMPBASE/case14home"
|
|
243
|
+
mkdir -p "$CREW_HOME/.pins/$UUID_TOMB"
|
|
244
|
+
echo x > "$CREW_HOME/.pins/$UUID_TOMB/worktree-lifecycle.sh"
|
|
245
|
+
out=$(CREW_ACTIVE_TASKS="" ORPHAN_SWEEP_REAP_AFTER=2 "$SWEEP" clean 2>&1); code=$?
|
|
246
|
+
[ "$(cat "$CREW_HOME/.pins/.sweep-suspects/$UUID_TOMB" 2>/dev/null)" = "1" ] && ok "14: tombstone planted by clean" || no "14: no tombstone after clean"
|
|
247
|
+
out=$(env -u CREW_ACTIVE_TASKS -u CREW_ACTIVE_TASKS_FILE ORPHAN_SWEEP_REAP_AFTER=2 "$SWEEP" clean 2>&1); code=$?
|
|
248
|
+
[ "$code" -eq 2 ] && ok "14: clean without active data exits 2" || no "14: exit $code, want 2"
|
|
249
|
+
echo "$out" | grep -q "BLOCKED" && ok "14: BLOCKED line printed" || no "14: no BLOCKED line in output"
|
|
250
|
+
[ "$(cat "$CREW_HOME/.pins/.sweep-suspects/$UUID_TOMB" 2>/dev/null)" = "1" ] && ok "14: marker still 1 after BLOCKED" || no "14: BLOCKED counted as an observation"
|
|
251
|
+
[ -d "$CREW_HOME/.pins/$UUID_TOMB" ] && ok "14: pin intact after BLOCKED" || no "14: pin removed despite BLOCKED"
|
|
252
|
+
|
|
253
|
+
# --- Case 15: markers for pins that no longer exist are pruned ---
|
|
254
|
+
new_fixture case15
|
|
255
|
+
export CREW_HOME="$TMPBASE/case15home"
|
|
256
|
+
mkdir -p "$CREW_HOME/.pins/.sweep-suspects"
|
|
257
|
+
printf '1\n' > "$CREW_HOME/.pins/.sweep-suspects/$UUID_TOMB" # pin dir gone (run cleaned it up itself)
|
|
258
|
+
out=$(CREW_ACTIVE_TASKS="" "$SWEEP" clean 2>&1); code=$?
|
|
259
|
+
[ "$code" -eq 0 ] && ok "15: clean exits 0" || no "15: exit $code, want 0"
|
|
260
|
+
[ ! -f "$CREW_HOME/.pins/.sweep-suspects/$UUID_TOMB" ] && ok "15: orphaned marker pruned" || no "15: orphaned marker still present"
|
|
261
|
+
|
|
262
|
+
# --- Case 16: invalid ORPHAN_SWEEP_REAP_AFTER fails closed ---
|
|
263
|
+
new_fixture case16
|
|
264
|
+
export CREW_HOME="$TMPBASE/case16home"
|
|
265
|
+
mkdir -p "$CREW_HOME/.pins/$UUID_TOMB"
|
|
266
|
+
echo x > "$CREW_HOME/.pins/$UUID_TOMB/worktree-lifecycle.sh"
|
|
267
|
+
out=$(CREW_ACTIVE_TASKS="" ORPHAN_SWEEP_REAP_AFTER=foo "$SWEEP" clean 2>&1); code=$?
|
|
268
|
+
[ "$code" -eq 2 ] && ok "16: non-numeric override exits 2" || no "16: exit $code, want 2"
|
|
269
|
+
echo "$out" | grep -q "BLOCKED" && ok "16: BLOCKED line printed" || no "16: no BLOCKED line in output"
|
|
270
|
+
[ -d "$CREW_HOME/.pins/$UUID_TOMB" ] && ok "16: pin untouched on invalid override" || no "16: pin removed despite invalid override"
|
|
271
|
+
out=$(CREW_ACTIVE_TASKS="" ORPHAN_SWEEP_REAP_AFTER=0 "$SWEEP" clean 2>&1); code=$?
|
|
272
|
+
[ "$code" -eq 2 ] && ok "16: zero override exits 2" || no "16: zero override exit $code, want 2"
|
|
273
|
+
|
|
187
274
|
echo ""
|
|
188
275
|
echo "== $pass passed, $fail failed =="
|
|
189
276
|
[ "$fail" -eq 0 ]
|
package/lib/test-publish-skip.sh
CHANGED
|
@@ -59,8 +59,9 @@ fi
|
|
|
59
59
|
bash "$MERGE_LOCK" acquire "$TASK" "$$" >/dev/null
|
|
60
60
|
OUT="$(bash "$LIFECYCLE" lock-status "$TASK" 2>&1)"
|
|
61
61
|
CODE=$?
|
|
62
|
-
if [ $CODE -eq 0 ] && printf '%s' "$OUT" | grep -Eq "^
|
|
63
|
-
|
|
62
|
+
if [ $CODE -eq 0 ] && printf '%s' "$OUT" | grep -Eq "^locked=true$" \
|
|
63
|
+
&& printf '%s' "$OUT" | grep -Eq "^task_id=$TASK$"; then
|
|
64
|
+
ok "lock-status reports key=value (locked=true, task_id=$TASK) (exit 0)"
|
|
64
65
|
else
|
|
65
66
|
no "lock-status LOCKED: code=$CODE out='$OUT'"
|
|
66
67
|
fi
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# test-publish-stranded.sh — regression test for the stranded-release resume
|
|
3
|
+
# in lib/publish-npm.sh (park 2026-09-11).
|
|
4
|
+
#
|
|
5
|
+
# A service restart can kill a Publish run between the release commit and the
|
|
6
|
+
# registry PUT. The release commit is pushed, but npm never got the tarball.
|
|
7
|
+
# Replaying the script must not fail the version-diff guard (the write is a
|
|
8
|
+
# no-op because package.json already carries the target): the script detects
|
|
9
|
+
# the stranded state and resumes at pack/publish. A package.json already at
|
|
10
|
+
# the target whose HEAD is NOT the script's own release commit is a foreign
|
|
11
|
+
# version bump and must fail closed.
|
|
12
|
+
#
|
|
13
|
+
# Self-contained: extracts the shipped STRANDED block from lib/publish-npm.sh
|
|
14
|
+
# by anchor and runs it against scratch checkouts.
|
|
15
|
+
#
|
|
16
|
+
# Anchors: `# STRANDED-ANCHOR` ... `# STRANDED-END` in lib/publish-npm.sh.
|
|
17
|
+
|
|
18
|
+
set -uo pipefail
|
|
19
|
+
|
|
20
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
21
|
+
PUBLISH="$SCRIPT_DIR/publish-npm.sh"
|
|
22
|
+
|
|
23
|
+
PKG="muse-crew"
|
|
24
|
+
TARGET_VERSION="9.9.9"
|
|
25
|
+
STRANDED_MSG="release: muse-crew@9.9.9 (task-aaa)"
|
|
26
|
+
STRANDED_MSG_BARE="release: muse-crew@9.9.9"
|
|
27
|
+
|
|
28
|
+
pass=0
|
|
29
|
+
fail_count=0
|
|
30
|
+
ok() { echo "PASS: $1"; pass=$((pass + 1)); }
|
|
31
|
+
no() { echo "FAIL: $1"; fail_count=$((fail_count + 1)); }
|
|
32
|
+
|
|
33
|
+
TMPBASE="$(mktemp -d)"
|
|
34
|
+
trap 'rm -rf "$TMPBASE"' EXIT
|
|
35
|
+
|
|
36
|
+
# --- Extract the shipped STRANDED block by anchor ---
|
|
37
|
+
BLOCK="$TMPBASE/stranded.sh"
|
|
38
|
+
sed -n '/# STRANDED-ANCHOR/,/# STRANDED-END/p' "$PUBLISH" > "$BLOCK"
|
|
39
|
+
if grep -q 'PUBLISH_STRANDED_RESUME=1' "$BLOCK" && grep -q 'fail "version-diff"' "$BLOCK"; then
|
|
40
|
+
ok "extracted STRANDED block from publish-npm.sh"
|
|
41
|
+
else
|
|
42
|
+
no "STRANDED extraction failed (anchors missing)"
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
# make_checkout <version> <head-msg>: a scratch git checkout whose
|
|
46
|
+
# package.json carries <version> and whose HEAD message is <head-msg>.
|
|
47
|
+
make_checkout() {
|
|
48
|
+
local ver="$1" msg="$2" dir="$TMPBASE/checkout-$RANDOM"
|
|
49
|
+
mkdir -p "$dir"
|
|
50
|
+
git -C "$dir" init -q
|
|
51
|
+
git -C "$dir" config user.email "test@test"
|
|
52
|
+
git -C "$dir" config user.name "test"
|
|
53
|
+
printf '{\n "name": "muse-crew",\n "version": "%s"\n}\n' "$ver" > "$dir/package.json"
|
|
54
|
+
git -C "$dir" add package.json
|
|
55
|
+
git -C "$dir" commit -q -m "$msg"
|
|
56
|
+
printf '%s' "$dir"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# run_stranded <checkout-dir> <head-msg>: runs the shipped block with
|
|
60
|
+
# PKG_JSON/PKG/TARGET_VERSION/HEAD_MSG set; captures stdout in $OUT.
|
|
61
|
+
run_stranded() {
|
|
62
|
+
local dir="$1" msg="$2" runner="$TMPBASE/runner-$RANDOM.sh"
|
|
63
|
+
{
|
|
64
|
+
echo 'fail() { echo "PUBLISH_FAILED=$1"; shift; echo "$1"; exit 1; }'
|
|
65
|
+
cat "$BLOCK"
|
|
66
|
+
echo 'echo "STRANDED_RESULT=$STRANDED"'
|
|
67
|
+
} > "$runner"
|
|
68
|
+
chmod +x "$runner"
|
|
69
|
+
OUT="$(PKG_JSON="$dir/package.json" PKG="$PKG" \
|
|
70
|
+
TARGET_VERSION="$TARGET_VERSION" HEAD_MSG="$msg" \
|
|
71
|
+
bash "$runner" 2>&1)"
|
|
72
|
+
local code=$?
|
|
73
|
+
echo "$OUT"
|
|
74
|
+
return "$code"
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# --- Case 1: stranded release commit (with task id) — resume, no write ---
|
|
78
|
+
DIR1="$(make_checkout "$TARGET_VERSION" "$STRANDED_MSG")"
|
|
79
|
+
BEFORE1="$(cat "$DIR1/package.json")"
|
|
80
|
+
OUT="$(run_stranded "$DIR1" "$STRANDED_MSG")"
|
|
81
|
+
CODE=$?
|
|
82
|
+
if [ "$CODE" -eq 0 ]; then
|
|
83
|
+
ok "stranded resume: block exits 0"
|
|
84
|
+
else
|
|
85
|
+
no "stranded resume: block exited $CODE (output: $OUT)"
|
|
86
|
+
fi
|
|
87
|
+
case "$OUT" in
|
|
88
|
+
*"PUBLISH_STRANDED_RESUME=1"*)
|
|
89
|
+
ok "stranded resume: prints PUBLISH_STRANDED_RESUME=1" ;;
|
|
90
|
+
*) no "stranded resume: missing PUBLISH_STRANDED_RESUME=1 (output: $OUT)" ;;
|
|
91
|
+
esac
|
|
92
|
+
case "$OUT" in
|
|
93
|
+
*"STRANDED_RESULT=1"*)
|
|
94
|
+
ok "stranded resume: STRANDED=1 (skips the version write)" ;;
|
|
95
|
+
*) no "stranded resume: expected STRANDED=1 (output: $OUT)" ;;
|
|
96
|
+
esac
|
|
97
|
+
if [ "$(cat "$DIR1/package.json")" = "$BEFORE1" ]; then
|
|
98
|
+
ok "stranded resume: package.json untouched (write skipped)"
|
|
99
|
+
else
|
|
100
|
+
no "stranded resume: package.json was modified"
|
|
101
|
+
fi
|
|
102
|
+
|
|
103
|
+
# --- Case 2: bare release message form (no task id) — also resumes ---
|
|
104
|
+
DIR2="$(make_checkout "$TARGET_VERSION" "$STRANDED_MSG_BARE")"
|
|
105
|
+
OUT="$(run_stranded "$DIR2" "$STRANDED_MSG_BARE")"
|
|
106
|
+
CODE=$?
|
|
107
|
+
if [ "$CODE" -eq 0 ] && grep -q 'PUBLISH_STRANDED_RESUME=1' <<< "$OUT"; then
|
|
108
|
+
ok "bare release message form also resumes"
|
|
109
|
+
else
|
|
110
|
+
no "bare release message form failed to resume (code $CODE, output: $OUT)"
|
|
111
|
+
fi
|
|
112
|
+
|
|
113
|
+
# --- Case 3: foreign version bump — fail closed ---
|
|
114
|
+
DIR3="$(make_checkout "$TARGET_VERSION" "merge: some other fix")"
|
|
115
|
+
OUT="$(run_stranded "$DIR3" "merge: some other fix")"
|
|
116
|
+
CODE=$?
|
|
117
|
+
if [ "$CODE" -ne 0 ]; then
|
|
118
|
+
ok "foreign version: block fails closed (non-zero exit)"
|
|
119
|
+
else
|
|
120
|
+
no "foreign version: block exited 0 despite foreign HEAD"
|
|
121
|
+
fi
|
|
122
|
+
case "$OUT" in
|
|
123
|
+
*"PUBLISH_FAILED=version-diff"*)
|
|
124
|
+
ok "foreign version: keeps the PUBLISH_FAILED=version-diff marker" ;;
|
|
125
|
+
*) no "foreign version: missing PUBLISH_FAILED=version-diff (output: $OUT)" ;;
|
|
126
|
+
esac
|
|
127
|
+
case "$OUT" in
|
|
128
|
+
*"not its release commit"*)
|
|
129
|
+
ok "foreign version: detail names the mismatch" ;;
|
|
130
|
+
*) no "foreign version: missing mismatch detail (output: $OUT)" ;;
|
|
131
|
+
esac
|
|
132
|
+
|
|
133
|
+
# --- Case 4: normal path (package.json behind) — no resume, write proceeds ---
|
|
134
|
+
DIR4="$(make_checkout "9.9.8" "merge: some other fix")"
|
|
135
|
+
OUT="$(run_stranded "$DIR4" "merge: some other fix")"
|
|
136
|
+
CODE=$?
|
|
137
|
+
if [ "$CODE" -eq 0 ]; then
|
|
138
|
+
ok "normal path: block exits 0"
|
|
139
|
+
else
|
|
140
|
+
no "normal path: block exited $CODE (output: $OUT)"
|
|
141
|
+
fi
|
|
142
|
+
case "$OUT" in
|
|
143
|
+
*"STRANDED_RESULT=0"*)
|
|
144
|
+
ok "normal path: STRANDED=0 (version write proceeds)" ;;
|
|
145
|
+
*) no "normal path: expected STRANDED=0 (output: $OUT)" ;;
|
|
146
|
+
esac
|
|
147
|
+
case "$OUT" in
|
|
148
|
+
*"PUBLISH_STRANDED_RESUME=1"*) no "normal path: must not print PUBLISH_STRANDED_RESUME=1" ;;
|
|
149
|
+
*) ok "normal path: no stranded-resume marker" ;;
|
|
150
|
+
esac
|
|
151
|
+
|
|
152
|
+
echo ""
|
|
153
|
+
echo "$pass passed, $fail_count failed."
|
|
154
|
+
[ "$fail_count" -eq 0 ]
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
# cleanup <task_id> — remove worktree + branch (no lock)
|
|
21
21
|
# status <task_id> — report worktree state
|
|
22
22
|
# refresh-lock <task_id> — refresh merge lock timestamp (resets staleness clock)
|
|
23
|
-
# lock-status <task_id> — report merge lock state: UNLOCKED or
|
|
23
|
+
# lock-status <task_id> — report merge lock state: UNLOCKED, or key=value lock status (locked=true, task_id, holder, acquired_at, lease_seconds, age_seconds, remaining_seconds)
|
|
24
24
|
# sweep [report|clean] — find/clean orphans + stale locks
|
|
25
25
|
#
|
|
26
26
|
# Exit codes:
|
package/package.json
CHANGED
|
@@ -384,12 +384,6 @@ if (parkJobs.length > 0) {
|
|
|
384
384
|
} catch (parkErr) {
|
|
385
385
|
log("WARNING: park batch failed (" + (parkErr.message || String(parkErr)).slice(0, 200) + ") — tasks remain retryable");
|
|
386
386
|
}
|
|
387
|
-
} else {
|
|
388
|
-
// Cap disabled, or no retry candidates: pre-cap behavior.
|
|
389
|
-
for (var dr = 0; dr < retryCandidates.length; dr++) {
|
|
390
|
-
var drc = retryCandidates[dr];
|
|
391
|
-
eligible.push({ task: drc.task, startStep: drc.startStep, reason: "retry", workflow: drc.workflow });
|
|
392
|
-
}
|
|
393
387
|
}
|
|
394
388
|
|
|
395
389
|
log("Eligible: " + eligible.length + " tasks (" + retryCandidates.length + " retry candidates)");
|
|
@@ -617,7 +611,20 @@ await agent(
|
|
|
617
611
|
{ key: "ack", label: "Acknowledging poll", schema: { type: "object" } }
|
|
618
612
|
);
|
|
619
613
|
|
|
620
|
-
var recommended =
|
|
614
|
+
var recommended = [];
|
|
615
|
+
var seenClaims = {};
|
|
616
|
+
results.forEach(function(r) {
|
|
617
|
+
if (r.action !== "recommended") { return; }
|
|
618
|
+
if (seenClaims[r.task_id]) {
|
|
619
|
+
// Mechanical guard: a single run must never emit the same task twice in
|
|
620
|
+
// its claims array. The self-claim stand-down path was designed for
|
|
621
|
+
// cross-tick races, not for one run claiming a task against itself.
|
|
622
|
+
log("WARNING: dropped duplicate claim for task " + r.task_id + " within this run (kept first)");
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
seenClaims[r.task_id] = true;
|
|
626
|
+
recommended.push(r);
|
|
627
|
+
});
|
|
621
628
|
var completed = results.filter(function(r) { return r.action === "completed"; });
|
|
622
629
|
var msg = "Dispatch complete.";
|
|
623
630
|
if (recommended.length > 0) {
|