muse-crew 0.4.6 → 0.5.0
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/docs/guide.md +1 -1
- package/lib/AGENTS.md +3 -2
- package/lib/publish-npm.sh +133 -105
- package/lib/test-publish-skip.sh +192 -0
- package/lib/worktree-lifecycle.sh +13 -1
- package/package.json +1 -1
- package/seed/AGENTS.md +1 -0
- package/seed/cron-sweep-body-template.md +11 -0
- package/workflows/bugfix.js +89 -26
- package/workflows/chore.js +86 -25
- package/workflows/crew-init.js +20 -25
- package/workflows/standard.js +89 -26
package/docs/guide.md
CHANGED
|
@@ -57,7 +57,7 @@ Required arguments:
|
|
|
57
57
|
Optional arguments:
|
|
58
58
|
- `dashboardName` — display name for the project registration (default: `"Muse Crew"`).
|
|
59
59
|
- `cronId` — polling cron identifier (default: `"crew-poll"`).
|
|
60
|
-
- `sweepCronId` — orphan sweep cron identifier (default: `"crew-sweep"`).
|
|
60
|
+
- `sweepCronId` — orphan sweep cron identifier (default: `"crew-orphan-sweep"`).
|
|
61
61
|
|
|
62
62
|
### What init creates
|
|
63
63
|
|
package/lib/AGENTS.md
CHANGED
|
@@ -5,10 +5,11 @@ 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/status/post-deploy 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.
|
|
8
|
+
- `worktree-lifecycle.sh` — the worktree lifecycle seam: prepare/cleanup/inspect/integrate/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.
|
|
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
|
-
- `test-version-write.sh` — regression tests for the escape-preserving step-8 version write in publish-npm.sh (fixture: current package.json with the
|
|
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
|
+
- `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
|
|
12
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
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.
|
|
14
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.
|
package/lib/publish-npm.sh
CHANGED
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
# On success: print the markers below, exit 0. The final marker is
|
|
24
24
|
# PUBLISH_COMPLETE=<version> — the Publish agent pastes it verbatim into its
|
|
25
25
|
# summary as the script's completion marker.
|
|
26
|
+
# When no merge lock is held (empty-diff Integrate), the publish path is
|
|
27
|
+
# skipped gracefully: PUBLISH_SKIPPED=no-lock-held is printed, no version
|
|
28
|
+
# is written or published, and PUBLISH_COMPLETE is omitted.
|
|
26
29
|
|
|
27
30
|
set -euo pipefail
|
|
28
31
|
|
|
@@ -65,113 +68,133 @@ if { [ "$HEAD_MSG" = "release: $PKG@$TARGET_VERSION ($TASK_ID)" ] \
|
|
|
65
68
|
echo "PUBLISH_VERIFIED=$TARGET_VERSION"
|
|
66
69
|
fi
|
|
67
70
|
|
|
71
|
+
NO_LOCK_HELD=0
|
|
68
72
|
if [ "$ALREADY_PUBLISHED" = "0" ]; then
|
|
69
|
-
# 5
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
#
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
| node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).version")" \
|
|
84
|
-
|| fail "read-base" "could not read version from HEAD:package.json"
|
|
85
|
-
echo "PUBLISH_BASE=$OLD"
|
|
86
|
-
|
|
87
|
-
# 8. Write TARGET_VERSION into package.json (`version` field only) via
|
|
88
|
-
# byte-preserving text substitution. A JSON.parse/stringify round-trip is
|
|
89
|
-
# forbidden here: it expands unicode escapes (e.g. the \u2014 escape in
|
|
90
|
-
# the description field) into literal characters, producing a two-line
|
|
91
|
-
# diff that trips the preservation check below (canary 5a027278). The
|
|
92
|
-
# substitution touches exactly one line; a missed substitution yields an
|
|
93
|
-
# empty diff and fails closed at the numstat check.
|
|
94
|
-
# STEP-8-ANCHOR: version write (escape-preserving)
|
|
95
|
-
PKG_JSON="$REPO_PATH/package.json"
|
|
96
|
-
ESCAPED_VER="$(printf '%s' "$TARGET_VERSION" | sed 's/[&\\]/\\&/g')"
|
|
97
|
-
sed -i -E 's/^([[:space:]]*"version"[[:space:]]*:[[:space:]]*)"[^"]*"/\1"'"$ESCAPED_VER"'"/' "$PKG_JSON" \
|
|
98
|
-
|| fail "version-write" "sed could not write $TARGET_VERSION into package.json"
|
|
99
|
-
# Read-only verification: parse package.json and confirm .version equals
|
|
100
|
-
# TARGET_VERSION. (Parse only — no rewrite, so no clock/randomness.)
|
|
101
|
-
NEW_VER="$(PKG_JSON="$PKG_JSON" node -p "JSON.parse(require('fs').readFileSync(process.env.PKG_JSON,'utf8')).version")" \
|
|
102
|
-
|| fail "version-write" "could not parse package.json after write"
|
|
103
|
-
[ "$NEW_VER" = "$TARGET_VERSION" ] \
|
|
104
|
-
|| fail "version-write" "package.json version is $NEW_VER, expected $TARGET_VERSION"
|
|
105
|
-
echo "PUBLISH_DIFF:"
|
|
106
|
-
git -C "$REPO_PATH" diff -- package.json
|
|
107
|
-
ADD_DEL="$(git -C "$REPO_PATH" diff --numstat -- package.json | tr '\t' ' ')"
|
|
108
|
-
[ "$ADD_DEL" = "1 1 package.json" ] \
|
|
109
|
-
|| fail "version-diff" "expected a single-line version change, got: $ADD_DEL"
|
|
110
|
-
# STEP-8-END
|
|
111
|
-
|
|
112
|
-
# 9. Commit only if changed (retried run leaves the commit in place).
|
|
113
|
-
if git -C "$REPO_PATH" diff --quiet -- package.json; then
|
|
114
|
-
echo "PUBLISH_COMMIT_EXISTS"
|
|
73
|
+
# STEP-5-ANCHOR: lock-state gate (empty-diff Integrate takes no lock).
|
|
74
|
+
# An empty-diff Integrate (MERGED_EMPTY) intentionally takes no merge
|
|
75
|
+
# lock: nothing merged, nothing to ship. When no lock is held, Publish
|
|
76
|
+
# skips the publish path gracefully instead of failing closed on
|
|
77
|
+
# refresh-lock (park 2026-09-11). UNLOCKED is explicit state, not prose.
|
|
78
|
+
# The anchor sits inside the ALREADY_PUBLISHED=0 branch, so the skip
|
|
79
|
+
# marker prints only on a genuinely unheld lock — a resumed run whose
|
|
80
|
+
# post-deploy already released the lock prints nothing here; its
|
|
81
|
+
# PUBLISHED_ALREADY/PUBLISH_VERIFIED markers already say it all.
|
|
82
|
+
LOCK_STATE="$(CREW_REPO="$REPO_PATH" bash "${LIFECYCLE:?LIFECYCLE is required}" lock-status "$TASK_ID" 2>&1)" \
|
|
83
|
+
|| fail "lock-status" "$LOCK_STATE"
|
|
84
|
+
if [ "$LOCK_STATE" = "UNLOCKED" ]; then
|
|
85
|
+
NO_LOCK_HELD=1
|
|
86
|
+
echo "PUBLISH_SKIPPED=no-lock-held"
|
|
115
87
|
else
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|| fail "commit" "git commit failed"
|
|
120
|
-
echo "PUBLISH_COMMIT=$(git -C "$REPO_PATH" rev-parse HEAD)"
|
|
88
|
+
# 5b. Refresh the merge lock (held from Integrate through post-deploy).
|
|
89
|
+
LOCK_OUT="$(CREW_REPO="$REPO_PATH" bash "${LIFECYCLE:?LIFECYCLE is required}" refresh-lock "$TASK_ID" 2>&1)" \
|
|
90
|
+
|| fail "lock-refresh" "$LOCK_OUT"
|
|
121
91
|
fi
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
[ "$TGZ_VER" = "$TARGET_VERSION" ] \
|
|
132
|
-
|| fail "pack-version-mismatch" "tarball has $TGZ_VER, expected $TARGET_VERSION"
|
|
133
|
-
|
|
134
|
-
# 11. Publish. "previously published versions" means a retried Publish
|
|
135
|
-
# already landed this version (the merge lock guarantees no other task
|
|
136
|
-
# picked it) — continue. Any other failure is fatal.
|
|
137
|
-
if PUB_OUT="$(python3 "$NPM_PUBLISH_PY" "$TGZ" 2>&1)"; then
|
|
138
|
-
echo "PUBLISHED=$TARGET_VERSION"
|
|
139
|
-
else
|
|
140
|
-
case "$PUB_OUT" in
|
|
141
|
-
*"previously published versions"*)
|
|
142
|
-
echo "PUBLISHED_ALREADY=$TARGET_VERSION"
|
|
143
|
-
;;
|
|
144
|
-
*)
|
|
145
|
-
rm -f "$TGZ"
|
|
146
|
-
fail "publish" "$(printf '%s\n' "$PUB_OUT" | tail -5)"
|
|
147
|
-
;;
|
|
92
|
+
# STEP-5-END
|
|
93
|
+
|
|
94
|
+
if [ "$NO_LOCK_HELD" = "0" ]; then
|
|
95
|
+
# 6. Install/activate the immutable release.
|
|
96
|
+
REL_OUT="$(bash "${RELEASE_SCRIPT:?RELEASE_SCRIPT is required}" deploy "$REPO_PATH" 2>&1)" \
|
|
97
|
+
|| fail "release-deploy" "$REL_OUT"
|
|
98
|
+
case "$REL_OUT" in
|
|
99
|
+
*INSTALLED*ACTIVATED*|*EXISTS*ACTIVATED*) ;;
|
|
100
|
+
*) fail "release-deploy" "unexpected output: $REL_OUT" ;;
|
|
148
101
|
esac
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
#
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
102
|
+
|
|
103
|
+
# 7. Read the old version (audit only — TARGET_VERSION stays authoritative).
|
|
104
|
+
OLD="$(git -C "$REPO_PATH" show "HEAD:package.json" \
|
|
105
|
+
| node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).version")" \
|
|
106
|
+
|| fail "read-base" "could not read version from HEAD:package.json"
|
|
107
|
+
echo "PUBLISH_BASE=$OLD"
|
|
108
|
+
|
|
109
|
+
# 8. Write TARGET_VERSION into package.json (`version` field only) via
|
|
110
|
+
# byte-preserving text substitution. A JSON.parse/stringify round-trip is
|
|
111
|
+
# forbidden here: it expands unicode escapes (e.g. the \u2014 escape in
|
|
112
|
+
# the description field) into literal characters, producing a two-line
|
|
113
|
+
# diff that trips the preservation check below (canary 5a027278). The
|
|
114
|
+
# substitution touches exactly one line; a missed substitution yields an
|
|
115
|
+
# empty diff and fails closed at the numstat check.
|
|
116
|
+
# STEP-8-ANCHOR: version write (escape-preserving)
|
|
117
|
+
PKG_JSON="$REPO_PATH/package.json"
|
|
118
|
+
ESCAPED_VER="$(printf '%s' "$TARGET_VERSION" | sed 's/[&\\]/\\&/g')"
|
|
119
|
+
sed -i -E 's/^([[:space:]]*"version"[[:space:]]*:[[:space:]]*)"[^"]*"/\1"'"$ESCAPED_VER"'"/' "$PKG_JSON" \
|
|
120
|
+
|| fail "version-write" "sed could not write $TARGET_VERSION into package.json"
|
|
121
|
+
# Read-only verification: parse package.json and confirm .version equals
|
|
122
|
+
# TARGET_VERSION. (Parse only — no rewrite, so no clock/randomness.)
|
|
123
|
+
NEW_VER="$(PKG_JSON="$PKG_JSON" node -p "JSON.parse(require('fs').readFileSync(process.env.PKG_JSON,'utf8')).version")" \
|
|
124
|
+
|| fail "version-write" "could not parse package.json after write"
|
|
125
|
+
[ "$NEW_VER" = "$TARGET_VERSION" ] \
|
|
126
|
+
|| fail "version-write" "package.json version is $NEW_VER, expected $TARGET_VERSION"
|
|
127
|
+
echo "PUBLISH_DIFF:"
|
|
128
|
+
git -C "$REPO_PATH" diff -- package.json
|
|
129
|
+
ADD_DEL="$(git -C "$REPO_PATH" diff --numstat -- package.json | tr '\t' ' ')"
|
|
130
|
+
[ "$ADD_DEL" = "1 1 package.json" ] \
|
|
131
|
+
|| fail "version-diff" "expected a single-line version change, got: $ADD_DEL"
|
|
132
|
+
# STEP-8-END
|
|
133
|
+
|
|
134
|
+
# 9. Commit only if changed (retried run leaves the commit in place).
|
|
135
|
+
if git -C "$REPO_PATH" diff --quiet -- package.json; then
|
|
136
|
+
echo "PUBLISH_COMMIT_EXISTS"
|
|
137
|
+
else
|
|
138
|
+
git -C "$REPO_PATH" add package.json \
|
|
139
|
+
|| fail "commit" "git add package.json failed"
|
|
140
|
+
git -C "$REPO_PATH" commit -m "release: $PKG@$TARGET_VERSION ($TASK_ID)" >/dev/null \
|
|
141
|
+
|| fail "commit" "git commit failed"
|
|
142
|
+
echo "PUBLISH_COMMIT=$(git -C "$REPO_PATH" rev-parse HEAD)"
|
|
143
|
+
fi
|
|
144
|
+
|
|
145
|
+
# 10. Pack and sanity-check the tarball's embedded version.
|
|
146
|
+
cd "$REPO_PATH" || fail "pack" "cannot cd to $REPO_PATH"
|
|
147
|
+
npm pack >/dev/null 2>&1 || fail "pack" "npm pack failed"
|
|
148
|
+
TGZ="$REPO_PATH/$PKG-$TARGET_VERSION.tgz"
|
|
149
|
+
[ -f "$TGZ" ] || fail "pack" "expected tarball $TGZ not produced"
|
|
150
|
+
TGZ_VER="$(tar -xzOf "$TGZ" package/package.json \
|
|
151
|
+
| node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).version")" \
|
|
152
|
+
|| fail "pack-version-mismatch" "could not read version from tarball $TGZ"
|
|
153
|
+
[ "$TGZ_VER" = "$TARGET_VERSION" ] \
|
|
154
|
+
|| fail "pack-version-mismatch" "tarball has $TGZ_VER, expected $TARGET_VERSION"
|
|
155
|
+
|
|
156
|
+
# 11. Publish. "previously published versions" means a retried Publish
|
|
157
|
+
# already landed this version (the merge lock guarantees no other task
|
|
158
|
+
# picked it) — continue. Any other failure is fatal.
|
|
159
|
+
if PUB_OUT="$(python3 "$NPM_PUBLISH_PY" "$TGZ" 2>&1)"; then
|
|
160
|
+
echo "PUBLISHED=$TARGET_VERSION"
|
|
161
|
+
else
|
|
162
|
+
case "$PUB_OUT" in
|
|
163
|
+
*"previously published versions"*)
|
|
164
|
+
echo "PUBLISHED_ALREADY=$TARGET_VERSION"
|
|
165
|
+
;;
|
|
166
|
+
*)
|
|
167
|
+
rm -f "$TGZ"
|
|
168
|
+
fail "publish" "$(printf '%s\n' "$PUB_OUT" | tail -5)"
|
|
169
|
+
;;
|
|
170
|
+
esac
|
|
171
|
+
fi
|
|
172
|
+
rm -f "$TGZ"
|
|
173
|
+
|
|
174
|
+
# 12. Verify: ground truth is the registry, not any agent's summary.
|
|
175
|
+
# The registry is eventually consistent: a publish followed by an
|
|
176
|
+
# immediate read can observe the pre-publish version (canary 5a027278
|
|
177
|
+
# hit a stale read replica ~6s after publish). A single stale read must
|
|
178
|
+
# never park a landed publish, so this block retries with backoff and
|
|
179
|
+
# client cache-busting instead of failing on the first mismatch.
|
|
180
|
+
# STEP-12-ANCHOR: publish verification (retry-tolerant)
|
|
181
|
+
VERIFY_ATTEMPTS="${VERIFY_ATTEMPTS:-12}"
|
|
182
|
+
VERIFY_SLEEP_SECS="${VERIFY_SLEEP_SECS:-10}"
|
|
183
|
+
REG=""
|
|
184
|
+
for attempt in $(seq 1 "$VERIFY_ATTEMPTS"); do
|
|
185
|
+
# --prefer-online busts npm's client-side packument cache; the retry
|
|
186
|
+
# loop absorbs server-side replica lag. A non-zero exit is a miss,
|
|
187
|
+
# not an immediate failure.
|
|
188
|
+
REG="$(npm view "$PKG" version --prefer-online 2>/dev/null || true)"
|
|
189
|
+
[ "$REG" = "$TARGET_VERSION" ] && break
|
|
190
|
+
echo "PUBLISH_VERIFY_RETRY=$attempt registry=$REG"
|
|
191
|
+
[ "$attempt" -lt "$VERIFY_ATTEMPTS" ] && sleep "$VERIFY_SLEEP_SECS"
|
|
192
|
+
done
|
|
193
|
+
[ "$REG" = "$TARGET_VERSION" ] \
|
|
194
|
+
|| fail "verify" "registry=$REG after $VERIFY_ATTEMPTS attempts"
|
|
195
|
+
echo "PUBLISH_VERIFIED=$TARGET_VERSION"
|
|
196
|
+
# STEP-12-END
|
|
197
|
+
fi # NO_LOCK_HELD=0: skip the mutation path when no lock is held
|
|
175
198
|
fi
|
|
176
199
|
|
|
177
200
|
# 12b. Refresh the merge lock again — release-deploy, pack, publish, and the
|
|
@@ -195,5 +218,10 @@ case "$FIN_OUT" in
|
|
|
195
218
|
*) fail "post-deploy" "missing DEPLOYED marker: $FIN_OUT" ;;
|
|
196
219
|
esac
|
|
197
220
|
|
|
198
|
-
# Final marker
|
|
199
|
-
|
|
221
|
+
# Final marker. On the skip path (no lock held) there was nothing to
|
|
222
|
+
# publish: PUBLISH_SKIPPED=no-lock-held is the completion marker and
|
|
223
|
+
# PUBLISH_COMPLETE is deliberately not printed — the phase completed,
|
|
224
|
+
# the publish did not happen.
|
|
225
|
+
if [ "$NO_LOCK_HELD" = "0" ]; then
|
|
226
|
+
echo "PUBLISH_COMPLETE=$TARGET_VERSION"
|
|
227
|
+
fi
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# test-publish-skip.sh — regression test for the graceful no-lock publish
|
|
3
|
+
# skip (park 2026-09-11: empty-diff Integrate takes no merge lock, but
|
|
4
|
+
# Publish ran refresh-lock unconditionally and failed closed).
|
|
5
|
+
#
|
|
6
|
+
# Covers:
|
|
7
|
+
# - worktree-lifecycle.sh lock-status: UNLOCKED (exit 0) with no lock;
|
|
8
|
+
# LOCKED by <holder> since <ts> (pid <pid>) when held (exit 0).
|
|
9
|
+
# - fake empty-diff integrate -> lock-status reports UNLOCKED (the lock
|
|
10
|
+
# is intentionally never taken on the MERGED_EMPTY path).
|
|
11
|
+
# - the shipped step-5 block of publish-npm.sh (extracted by anchor):
|
|
12
|
+
# * UNLOCKED -> NO_LOCK_HELD=1, PUBLISH_SKIPPED=no-lock-held, exit 0
|
|
13
|
+
# * LOCKED (self) -> refresh path runs, NO_LOCK_HELD=0, exit 0
|
|
14
|
+
# * lock-status query failure -> fail "lock-status", exit 1 (fail-closed)
|
|
15
|
+
# - structural: the step-5 anchor sits inside the ALREADY_PUBLISHED=0
|
|
16
|
+
# branch (a resumed run whose post-deploy released the lock prints
|
|
17
|
+
# nothing), and the mutation steps are gated on NO_LOCK_HELD=0.
|
|
18
|
+
#
|
|
19
|
+
# Anchors: `# STEP-5-ANCHOR` ... `# STEP-5-END` in lib/publish-npm.sh.
|
|
20
|
+
|
|
21
|
+
set -uo pipefail
|
|
22
|
+
|
|
23
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
24
|
+
LIFECYCLE="$SCRIPT_DIR/worktree-lifecycle.sh"
|
|
25
|
+
MERGE_LOCK="$SCRIPT_DIR/merge-lock.sh"
|
|
26
|
+
PUBLISH="$SCRIPT_DIR/publish-npm.sh"
|
|
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
|
+
# --- Scratch repo ---
|
|
37
|
+
SCRATCH="$TMPBASE/repo"
|
|
38
|
+
git init -q -b main "$SCRATCH"
|
|
39
|
+
git -C "$SCRATCH" config user.email "test@crew"
|
|
40
|
+
git -C "$SCRATCH" config user.name "crew-test"
|
|
41
|
+
echo ".worktrees/" > "$SCRATCH/.gitignore"
|
|
42
|
+
echo x > "$SCRATCH/f.txt"
|
|
43
|
+
git -C "$SCRATCH" add -A && git -C "$SCRATCH" commit -qm init
|
|
44
|
+
|
|
45
|
+
export CREW_REPO="$SCRATCH" CREW_LIB="$SCRIPT_DIR"
|
|
46
|
+
|
|
47
|
+
TASK="skiptest-task-1"
|
|
48
|
+
|
|
49
|
+
# --- lock-status with no lock held ---
|
|
50
|
+
OUT="$(bash "$LIFECYCLE" lock-status "$TASK" 2>&1)"
|
|
51
|
+
CODE=$?
|
|
52
|
+
if [ $CODE -eq 0 ] && [ "$OUT" = "UNLOCKED" ]; then
|
|
53
|
+
ok "lock-status reports UNLOCKED with no lock held (exit 0)"
|
|
54
|
+
else
|
|
55
|
+
no "lock-status UNLOCKED: code=$CODE out='$OUT'"
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
# --- lock-status with a lock held ---
|
|
59
|
+
bash "$MERGE_LOCK" acquire "$TASK" "$$" >/dev/null
|
|
60
|
+
OUT="$(bash "$LIFECYCLE" lock-status "$TASK" 2>&1)"
|
|
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)"
|
|
64
|
+
else
|
|
65
|
+
no "lock-status LOCKED: code=$CODE out='$OUT'"
|
|
66
|
+
fi
|
|
67
|
+
bash "$MERGE_LOCK" release "$TASK" >/dev/null
|
|
68
|
+
|
|
69
|
+
# --- fake empty-diff integrate: MERGED_EMPTY takes no lock ---
|
|
70
|
+
bash "$LIFECYCLE" prepare "$TASK" >/dev/null 2>&1
|
|
71
|
+
INTEG="$(bash "$LIFECYCLE" integrate "$TASK" "merge: $TASK" 2>&1)"
|
|
72
|
+
if [[ "$INTEG" == MERGED_EMPTY* ]]; then
|
|
73
|
+
ok "fake empty-diff integrate takes the MERGED_EMPTY branch"
|
|
74
|
+
else
|
|
75
|
+
no "empty-diff integrate: '$INTEG'"
|
|
76
|
+
fi
|
|
77
|
+
OUT="$(bash "$LIFECYCLE" lock-status "$TASK" 2>&1)"
|
|
78
|
+
if [ "$OUT" = "UNLOCKED" ]; then
|
|
79
|
+
ok "after MERGED_EMPTY integrate the lock is still UNLOCKED (never taken)"
|
|
80
|
+
else
|
|
81
|
+
no "lock held after empty-diff integrate: '$OUT'"
|
|
82
|
+
fi
|
|
83
|
+
bash "$LIFECYCLE" cleanup "$TASK" >/dev/null 2>&1
|
|
84
|
+
|
|
85
|
+
# --- Extract the shipped step-5 block by anchor ---
|
|
86
|
+
BLOCK="$TMPBASE/step5.sh"
|
|
87
|
+
sed -n '/# STEP-5-ANCHOR/,/# STEP-5-END/p' "$PUBLISH" > "$BLOCK"
|
|
88
|
+
if grep -q 'lock-status' "$BLOCK" && grep -q 'PUBLISH_SKIPPED=no-lock-held' "$BLOCK" \
|
|
89
|
+
&& grep -q 'fail "lock-status"' "$BLOCK" && grep -q 'NO_LOCK_HELD=1' "$BLOCK"; then
|
|
90
|
+
ok "extracted step-5 block from publish-npm.sh"
|
|
91
|
+
else
|
|
92
|
+
no "step-5 extraction failed (anchors missing)"
|
|
93
|
+
fi
|
|
94
|
+
|
|
95
|
+
# Runner: provides fail(), env, sources the extracted block, then reports
|
|
96
|
+
# NO_LOCK_HELD and the exit code. Never calls fail = never parks.
|
|
97
|
+
run_block() { # run_block <lock_setup: none|held|broken>
|
|
98
|
+
local mode="$1"
|
|
99
|
+
local run="$TMPBASE/run-$mode"
|
|
100
|
+
mkdir -p "$run"
|
|
101
|
+
case "$mode" in
|
|
102
|
+
none) : ;;
|
|
103
|
+
held) bash "$MERGE_LOCK" acquire "$TASK" "$$" >/dev/null ;;
|
|
104
|
+
broken) : ;;
|
|
105
|
+
esac
|
|
106
|
+
(
|
|
107
|
+
set -euo pipefail
|
|
108
|
+
export CREW_REPO="$SCRATCH" TASK_ID="$TASK" REPO_PATH="$SCRATCH"
|
|
109
|
+
if [ "$mode" = "broken" ]; then
|
|
110
|
+
export LIFECYCLE="/nonexistent/lifecycle.sh"
|
|
111
|
+
else
|
|
112
|
+
export LIFECYCLE="$LIFECYCLE"
|
|
113
|
+
fi
|
|
114
|
+
fail() { echo "PUBLISH_FAILED=$1"; [ $# -ge 2 ] && echo "$2"; exit 1; }
|
|
115
|
+
NO_LOCK_HELD=0
|
|
116
|
+
# shellcheck disable=SC1090
|
|
117
|
+
source "$BLOCK"
|
|
118
|
+
echo "RESULT_NO_LOCK_HELD=$NO_LOCK_HELD"
|
|
119
|
+
) > "$run/out" 2>&1
|
|
120
|
+
local code=$?
|
|
121
|
+
echo "$code" > "$run/code"
|
|
122
|
+
case "$mode" in
|
|
123
|
+
held) bash "$MERGE_LOCK" release "$TASK" >/dev/null 2>&1 || true ;;
|
|
124
|
+
esac
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
# --- UNLOCKED: skip gracefully, never park ---
|
|
128
|
+
run_block none
|
|
129
|
+
CODE="$(cat "$TMPBASE/run-none/code")"
|
|
130
|
+
OUT="$(cat "$TMPBASE/run-none/out")"
|
|
131
|
+
if [ "$CODE" -eq 0 ] && [[ "$OUT" == *"PUBLISH_SKIPPED=no-lock-held"* ]] \
|
|
132
|
+
&& [[ "$OUT" == *"RESULT_NO_LOCK_HELD=1"* ]] && [[ "$OUT" != *"PUBLISH_FAILED"* ]]; then
|
|
133
|
+
ok "step-5 on UNLOCKED: skips gracefully (NO_LOCK_HELD=1, marker printed, exit 0)"
|
|
134
|
+
else
|
|
135
|
+
no "step-5 UNLOCKED: code=$CODE out='$OUT'"
|
|
136
|
+
fi
|
|
137
|
+
|
|
138
|
+
# --- LOCKED by self: refresh path runs, no skip ---
|
|
139
|
+
run_block held
|
|
140
|
+
CODE="$(cat "$TMPBASE/run-held/code")"
|
|
141
|
+
OUT="$(cat "$TMPBASE/run-held/out")"
|
|
142
|
+
if [ "$CODE" -eq 0 ] && [[ "$OUT" != *"PUBLISH_SKIPPED"* ]] \
|
|
143
|
+
&& [[ "$OUT" == *"RESULT_NO_LOCK_HELD=0"* ]] && [[ "$OUT" != *"PUBLISH_FAILED"* ]]; then
|
|
144
|
+
ok "step-5 on LOCKED (self): refresh path runs, no skip marker, exit 0"
|
|
145
|
+
else
|
|
146
|
+
no "step-5 LOCKED: code=$CODE out='$OUT'"
|
|
147
|
+
fi
|
|
148
|
+
|
|
149
|
+
# --- lock-status query failure: fail closed ---
|
|
150
|
+
run_block broken
|
|
151
|
+
CODE="$(cat "$TMPBASE/run-broken/code")"
|
|
152
|
+
OUT="$(cat "$TMPBASE/run-broken/out")"
|
|
153
|
+
if [ "$CODE" -ne 0 ] && [[ "$OUT" == *"PUBLISH_FAILED=lock-status"* ]]; then
|
|
154
|
+
ok "step-5 on lock-status failure: fail-closed PUBLISH_FAILED=lock-status"
|
|
155
|
+
else
|
|
156
|
+
no "step-5 broken query: code=$CODE out='$OUT'"
|
|
157
|
+
fi
|
|
158
|
+
|
|
159
|
+
# --- Structural: the gate sits inside the ALREADY_PUBLISHED=0 branch ---
|
|
160
|
+
# (a resumed run whose post-deploy already released the lock must print
|
|
161
|
+
# nothing — its PUBLISHED_ALREADY/PUBLISH_VERIFIED markers say it all).
|
|
162
|
+
AP_LINE="$(grep -n 'if \[ "\$ALREADY_PUBLISHED" = "0" \]; then' "$PUBLISH" | head -1 | cut -d: -f1)"
|
|
163
|
+
ANCHOR_LINE="$(grep -n '# STEP-5-ANCHOR' "$PUBLISH" | head -1 | cut -d: -f1)"
|
|
164
|
+
END_LINE="$(grep -n '# STEP-5-END' "$PUBLISH" | head -1 | cut -d: -f1)"
|
|
165
|
+
if [ -n "$AP_LINE" ] && [ -n "$ANCHOR_LINE" ] && [ -n "$END_LINE" ] \
|
|
166
|
+
&& [ "$AP_LINE" -lt "$ANCHOR_LINE" ] && [ "$ANCHOR_LINE" -lt "$END_LINE" ]; then
|
|
167
|
+
ok "step-5 anchor sits inside the ALREADY_PUBLISHED=0 branch"
|
|
168
|
+
else
|
|
169
|
+
no "step-5 anchor placement wrong (ap=$AP_LINE anchor=$ANCHOR_LINE end=$END_LINE)"
|
|
170
|
+
fi
|
|
171
|
+
|
|
172
|
+
# --- Structural: mutation steps are gated on NO_LOCK_HELD=0 ---
|
|
173
|
+
if grep -q 'if \[ "\$NO_LOCK_HELD" = "0" \]; then' "$PUBLISH" \
|
|
174
|
+
&& grep -q 'fi # NO_LOCK_HELD=0' "$PUBLISH"; then
|
|
175
|
+
ok "mutation path is gated on NO_LOCK_HELD=0"
|
|
176
|
+
else
|
|
177
|
+
no "NO_LOCK_HELD gate missing in publish-npm.sh"
|
|
178
|
+
fi
|
|
179
|
+
|
|
180
|
+
# --- Structural: PUBLISH_COMPLETE is omitted on the skip path ---
|
|
181
|
+
if grep -q 'PUBLISH_COMPLETE is deliberately not printed' "$PUBLISH"; then
|
|
182
|
+
ok "skip path omits PUBLISH_COMPLETE (no false publish claim)"
|
|
183
|
+
else
|
|
184
|
+
no "PUBLISH_COMPLETE skip-path handling missing"
|
|
185
|
+
fi
|
|
186
|
+
|
|
187
|
+
bash -n "$LIFECYCLE" && ok "worktree-lifecycle.sh syntax OK" || no "worktree-lifecycle.sh syntax"
|
|
188
|
+
bash -n "$PUBLISH" && ok "publish-npm.sh syntax OK" || no "publish-npm.sh syntax"
|
|
189
|
+
|
|
190
|
+
echo ""
|
|
191
|
+
echo "$pass passed, $fail_count failed"
|
|
192
|
+
exit $((fail_count > 0))
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
# cleanup <task_id> — remove worktree + branch (no lock)
|
|
20
20
|
# status <task_id> — report worktree state
|
|
21
21
|
# refresh-lock <task_id> — refresh merge lock timestamp (resets staleness clock)
|
|
22
|
+
# lock-status <task_id> — report merge lock state: UNLOCKED or LOCKED by <holder> since <ts> (pid <pid>)
|
|
22
23
|
# sweep [report|clean] — find/clean orphans + stale locks
|
|
23
24
|
#
|
|
24
25
|
# Exit codes:
|
|
@@ -367,6 +368,16 @@ cmd_refresh_lock() {
|
|
|
367
368
|
"$MERGE_LOCK" refresh "$task_id"
|
|
368
369
|
}
|
|
369
370
|
|
|
371
|
+
cmd_lock_status() {
|
|
372
|
+
local task_id="$1"
|
|
373
|
+
validate_task_id "$task_id"
|
|
374
|
+
# Passthrough to the lock's own status readout. Scripts never call
|
|
375
|
+
# merge-lock.sh directly — this keeps the seam. Always exits 0:
|
|
376
|
+
# UNLOCKED is explicit state (an empty-diff Integrate intentionally
|
|
377
|
+
# takes no lock), never an error.
|
|
378
|
+
"$MERGE_LOCK" status
|
|
379
|
+
}
|
|
380
|
+
|
|
370
381
|
# --- dispatch ---
|
|
371
382
|
|
|
372
383
|
cmd="${1:-}"
|
|
@@ -381,10 +392,11 @@ case "$cmd" in
|
|
|
381
392
|
cleanup) cmd_cleanup "${1:?task_id required}" ;;
|
|
382
393
|
status) cmd_status "${1:?task_id required}" ;;
|
|
383
394
|
refresh-lock) cmd_refresh_lock "${1:?task_id required}" ;;
|
|
395
|
+
lock-status) cmd_lock_status "${1:?task_id required}" ;;
|
|
384
396
|
sweep) cmd_sweep "${1:-report}" ;;
|
|
385
397
|
*)
|
|
386
398
|
echo "Usage: worktree-lifecycle.sh <command> [args]"
|
|
387
|
-
echo "Commands: validate, prepare, inspect, integrate, post-deploy, cleanup, status, refresh-lock, sweep"
|
|
399
|
+
echo "Commands: validate, prepare, inspect, integrate, post-deploy, cleanup, status, refresh-lock, lock-status, sweep"
|
|
388
400
|
exit 1
|
|
389
401
|
;;
|
|
390
402
|
esac
|
package/package.json
CHANGED
package/seed/AGENTS.md
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
Init source data. Everything `crew-init.js` reads when setting up a new crew instance on a Muse account.
|
|
4
4
|
|
|
5
5
|
- `cron-body-template.md` — template for creating the dispatch cron job
|
|
6
|
+
- `cron-sweep-body-template.md` — template for creating the orphan-sweep cron job
|
|
6
7
|
- `posture.md` — PM posture that shapes the user's assistant for crew interaction
|
|
7
8
|
- `feedback/` — feedback feature placeholder
|
|
8
9
|
- `workflows/` — human-readable workflow definitions, copied to `$CREW_HOME/.orchestration/workflows/` during init
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
## Muse Crew Orphan Sweep
|
|
2
|
+
|
|
3
|
+
1. Call artifact_invoke_action on slug "{dashboardSlug}", action "getdispatchstate", args {}.
|
|
4
|
+
2. From the response, extract the `id` field of each `ready_tasks` entry whose
|
|
5
|
+
latest_session.status is "running", verbatim — copy the full ID exactly
|
|
6
|
+
as it appears; do not shorten, truncate, or reformat it. Return them as a
|
|
7
|
+
JSON array of strings. Do NOT reproduce any other part of the response.
|
|
8
|
+
(Sessions older than 1h are already reclassified as timed_out by the dashboard — no extra math.)
|
|
9
|
+
3. Run: CREW_ACTIVE_TASKS="<space-separated ids>" {crewHome}/lib/orphan-sweep.sh clean
|
|
10
|
+
If the list is empty, pass an empty string — do NOT omit the variable (the sweep fails closed without it).
|
|
11
|
+
4. Report the output. If it says CLEAN, no action was needed.
|