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 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 `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.
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)
@@ -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
- # Pins live on persistent disk ($CREW_HOME/.pins/<task>) so a reboot can't
196
- # strand a run; pins for tasks with no running session are garbage.
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
- echo "STALE_PIN: $task_id no running session, safe to remove"
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
@@ -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).
@@ -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 ]
@@ -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 "^LOCKED by $TASK since .+ \(pid [0-9]+\)$"; then
63
- ok "lock-status reports LOCKED by <holder> since <ts> (pid <pid>) (exit 0)"
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 LOCKED by <holder> since <ts> (pid <pid>)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muse-crew",
3
- "version": "0.6.4",
3
+ "version": "0.6.6",
4
4
  "description": "Opinionated orchestration for Muse \u2014 workflows, identities, and tooling for autonomous software development.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -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 = results.filter(function(r) { return r.action === "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) {