muse-crew 0.6.5 → 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,7 +5,7 @@ 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
@@ -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).
@@ -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.5",
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) {