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 +1 -1
- package/lib/publish-npm.sh +23 -0
- 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,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
|
|
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
|
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-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) {
|