muse-crew 0.4.3 → 0.4.5
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/AGENTS.md +2 -0
- package/API.md +68 -21
- package/README.md +18 -2
- package/docs/guide.md +81 -11
- package/docs/visual-verdict.md +120 -0
- package/identities/hazel.md +22 -0
- package/lib/AGENTS.md +6 -1
- package/lib/build-registry.js +80 -0
- package/lib/compose-evidence.py +144 -0
- package/lib/crew-release.sh +9 -1
- package/lib/merge-lock.sh +18 -0
- package/lib/orphan-sweep.sh +70 -7
- package/lib/publish-npm.sh +38 -14
- package/lib/test-merge-lock.sh +69 -0
- package/lib/test-orphan-sweep.sh +74 -8
- package/lib/test-publish-verify.sh +157 -0
- package/lib/test-version-write.sh +102 -0
- package/lib/test-worktree-backend.sh +79 -0
- package/lib/worktree-lifecycle.sh +69 -28
- package/package.json +1 -1
- package/seed/cron-body-template.md +5 -3
- package/seed/workflows/bugfix.md +7 -2
- package/seed/workflows/chore.md +7 -3
- package/seed/workflows/standard.md +7 -2
- package/workflows/AGENTS.md +4 -4
- package/workflows/bugfix.js +1023 -173
- package/workflows/chore.js +888 -146
- package/workflows/crew-dispatch.js +405 -98
- package/workflows/crew-init.js +4 -35
- package/workflows/docs.js +342 -22
- package/workflows/standard.js +1024 -172
- package/workflows/tests/retry-cap.test.mjs +102 -0
- package/workflows/tests/work-agent-failure.test.mjs +122 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""compose-evidence.py — deterministic visual-evidence compositor.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
python3 lib/compose-evidence.py <baseline-dir> <postchange-dir> <composites-dir>
|
|
6
|
+
|
|
7
|
+
Pairs PNG captures with identical file stems from the baseline and
|
|
8
|
+
post-change evidence directories and emits two composites per pair into
|
|
9
|
+
the composites directory:
|
|
10
|
+
|
|
11
|
+
<stem>-sidebyside.png baseline | postchange, side by side with a divider
|
|
12
|
+
<stem>-overlay.png amplified absolute-difference image
|
|
13
|
+
|
|
14
|
+
Stdout is machine-readable: one `PAIR <stem> -> ...` line per composite
|
|
15
|
+
pair, one `SKIP <stem> (<reason>)` line per stem that has no counterpart.
|
|
16
|
+
|
|
17
|
+
Determinism contract: no timestamps, no randomness, no network. Same
|
|
18
|
+
inputs always produce byte-identical outputs. Exit code is nonzero on
|
|
19
|
+
errors (bad arguments, missing directories, unreadable images).
|
|
20
|
+
|
|
21
|
+
Requires Pillow (PIL).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
from PIL import Image, ImageChops
|
|
29
|
+
except ImportError:
|
|
30
|
+
Image = None
|
|
31
|
+
ImageChops = None
|
|
32
|
+
|
|
33
|
+
DIVIDER_PX = 2
|
|
34
|
+
BACKGROUND = (240, 240, 240)
|
|
35
|
+
DIVIDER_COLOR = (32, 32, 32)
|
|
36
|
+
# Difference amplification factor: |a-b| * k, clipped to 255. Fixed at 4 —
|
|
37
|
+
# enough to make sub-perceptual pixel shifts visible, deterministic.
|
|
38
|
+
DIFF_AMPLIFY = 4
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def fail(msg):
|
|
42
|
+
print("ERROR: " + msg, file=sys.stderr)
|
|
43
|
+
sys.exit(1)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def main():
|
|
47
|
+
if len(sys.argv) != 4:
|
|
48
|
+
fail("usage: compose-evidence.py <baseline-dir> <postchange-dir> <composites-dir>")
|
|
49
|
+
baseline_dir, postchange_dir, composites_dir = sys.argv[1:4]
|
|
50
|
+
for label, d in (("baseline", baseline_dir), ("postchange", postchange_dir)):
|
|
51
|
+
if not os.path.isdir(d):
|
|
52
|
+
fail(label + " dir is not a directory: " + d)
|
|
53
|
+
try:
|
|
54
|
+
os.makedirs(composites_dir, exist_ok=True)
|
|
55
|
+
except OSError as e:
|
|
56
|
+
fail("cannot create composites dir " + composites_dir + ": " + e)
|
|
57
|
+
|
|
58
|
+
if Image is None or ImageChops is None:
|
|
59
|
+
fail("Pillow (PIL) is required but not installed")
|
|
60
|
+
|
|
61
|
+
def stems(d):
|
|
62
|
+
out = []
|
|
63
|
+
for name in os.listdir(d):
|
|
64
|
+
if name.lower().endswith(".png"):
|
|
65
|
+
full = os.path.join(d, name)
|
|
66
|
+
if os.path.isfile(full):
|
|
67
|
+
out.append(name[:-4])
|
|
68
|
+
return sorted(out)
|
|
69
|
+
|
|
70
|
+
base_stems = stems(baseline_dir)
|
|
71
|
+
post_stems = stems(postchange_dir)
|
|
72
|
+
post_set = set(post_stems)
|
|
73
|
+
|
|
74
|
+
exit_code = 0
|
|
75
|
+
|
|
76
|
+
def load(d, stem):
|
|
77
|
+
return Image.open(os.path.join(d, stem + ".png")).convert("RGB")
|
|
78
|
+
|
|
79
|
+
for stem in base_stems:
|
|
80
|
+
if stem not in post_set:
|
|
81
|
+
print("SKIP " + stem + " (only in baseline)")
|
|
82
|
+
continue
|
|
83
|
+
try:
|
|
84
|
+
base = load(baseline_dir, stem)
|
|
85
|
+
post = load(postchange_dir, stem)
|
|
86
|
+
except Exception as e:
|
|
87
|
+
print("ERROR: cannot read pair " + stem + ": " + str(e), file=sys.stderr)
|
|
88
|
+
exit_code = 1
|
|
89
|
+
continue
|
|
90
|
+
try:
|
|
91
|
+
sidebyside = make_sidebyside(base, post)
|
|
92
|
+
side_name = stem + "-sidebyside.png"
|
|
93
|
+
sidebyside.save(os.path.join(composites_dir, side_name), format="PNG")
|
|
94
|
+
|
|
95
|
+
overlay = make_overlay(base, post)
|
|
96
|
+
over_name = stem + "-overlay.png"
|
|
97
|
+
overlay.save(os.path.join(composites_dir, over_name), format="PNG")
|
|
98
|
+
except Exception as e:
|
|
99
|
+
print("ERROR: cannot compose pair " + stem + ": " + str(e), file=sys.stderr)
|
|
100
|
+
exit_code = 1
|
|
101
|
+
continue
|
|
102
|
+
print("PAIR " + stem + " -> " + side_name + " " + over_name)
|
|
103
|
+
|
|
104
|
+
for stem in post_stems:
|
|
105
|
+
if stem not in set(base_stems):
|
|
106
|
+
print("SKIP " + stem + " (only in postchange)")
|
|
107
|
+
|
|
108
|
+
return exit_code
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def pad_to(image, width, height):
|
|
112
|
+
"""Return image pasted top-left on a BACKGROUND canvas of (width, height)."""
|
|
113
|
+
canvas = Image.new("RGB", (width, height), BACKGROUND)
|
|
114
|
+
canvas.paste(image, (0, 0))
|
|
115
|
+
return canvas
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def make_sidebyside(base, post):
|
|
119
|
+
"""Baseline left, post-change right, separated by a divider column."""
|
|
120
|
+
height = max(base.height, post.height)
|
|
121
|
+
base_p = pad_to(base, base.width, height)
|
|
122
|
+
post_p = pad_to(post, post.width, height)
|
|
123
|
+
total = base_p.width + DIVIDER_PX + post_p.width
|
|
124
|
+
canvas = Image.new("RGB", (total, height), BACKGROUND)
|
|
125
|
+
canvas.paste(base_p, (0, 0))
|
|
126
|
+
divider = Image.new("RGB", (DIVIDER_PX, height), DIVIDER_COLOR)
|
|
127
|
+
canvas.paste(divider, (base_p.width, 0))
|
|
128
|
+
canvas.paste(post_p, (base_p.width + DIVIDER_PX, 0))
|
|
129
|
+
return canvas
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def make_overlay(base, post):
|
|
133
|
+
"""Amplified absolute per-channel difference; identical pixels stay black."""
|
|
134
|
+
width = max(base.width, post.width)
|
|
135
|
+
height = max(base.height, post.height)
|
|
136
|
+
base_p = pad_to(base, width, height)
|
|
137
|
+
post_p = pad_to(post, width, height)
|
|
138
|
+
diff = ImageChops.difference(base_p, post_p)
|
|
139
|
+
# Fixed amplification, clipped — deterministic and byte-stable.
|
|
140
|
+
return diff.point(lambda v: v * DIFF_AMPLIFY if v * DIFF_AMPLIFY < 255 else 255)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
if __name__ == "__main__":
|
|
144
|
+
sys.exit(main())
|
package/lib/crew-release.sh
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
# # release on every deploy/rollback
|
|
16
16
|
#
|
|
17
17
|
# The Muse workflow runtime snapshots .js scripts at launch.
|
|
18
|
-
#
|
|
18
|
+
# $CREW_HOME/.pins/<task> pins lifecycle scripts per run (persistent disk —
|
|
19
|
+
# /tmp is tmpfs and cell reboots wipe it mid-run).
|
|
19
20
|
# The merge lock serializes deploys at the integration step.
|
|
20
21
|
# This script handles only: atomic activation, version identity, rollback.
|
|
21
22
|
#
|
|
@@ -171,6 +172,13 @@ cmd_deploy() {
|
|
|
171
172
|
cp -r seed/workflows "$staging_dir/seed/"
|
|
172
173
|
fi
|
|
173
174
|
fi
|
|
175
|
+
# Build the static workflow registry from the workflow files' meta blocks.
|
|
176
|
+
# The workflow files remain the single source of truth; registry.json is a
|
|
177
|
+
# generated build artifact and must never be hand-edited.
|
|
178
|
+
if ! node "$staging_dir/lib/build-registry.js" "$staging_dir/workflows" "$staging_dir/workflows/registry.json"; then
|
|
179
|
+
rm -rf "$staging_dir"
|
|
180
|
+
die "release $hash rejected: registry build failed"
|
|
181
|
+
fi
|
|
174
182
|
# Gate: refuse to install a release whose workflow scripts don't parse.
|
|
175
183
|
if ! _validate_workflows "$staging_dir"; then
|
|
176
184
|
rm -rf "$staging_dir"
|
package/lib/merge-lock.sh
CHANGED
|
@@ -27,6 +27,24 @@ case "$cmd" in
|
|
|
27
27
|
holder=$(cut -d' ' -f1 "$LOCK_FILE" 2>/dev/null || echo "unknown")
|
|
28
28
|
acquired_at=$(cut -d' ' -f2 "$LOCK_FILE" 2>/dev/null || echo "unknown")
|
|
29
29
|
lock_pid=$(awk '{print $3}' "$LOCK_FILE" 2>/dev/null || echo "-")
|
|
30
|
+
# Self-healing: if the holder's PID is dead, missing, or invalid, the lock
|
|
31
|
+
# is stale (the workflow that acquired it terminated without releasing
|
|
32
|
+
# via post-deploy). Break the stale lock and retry the acquire. A live
|
|
33
|
+
# PID means the lock is genuinely held — report HELD. This makes terminal
|
|
34
|
+
# cleanup mechanical: a stopped/failed workflow's lock is reclaimed on
|
|
35
|
+
# the next acquire, not via manual release or a background sweeper.
|
|
36
|
+
if [ -z "$lock_pid" ] || [ "$lock_pid" = "-" ] || ! kill -0 "$lock_pid" 2>/dev/null; then
|
|
37
|
+
echo "STALE: lock held by $holder since $acquired_at (pid $lock_pid dead) — breaking" >&2
|
|
38
|
+
rm -f "$LOCK_FILE"
|
|
39
|
+
if (set -C; echo "$task_id $(date -u +%Y-%m-%dT%H:%M:%SZ) $owner_pid" > "$LOCK_FILE") 2>/dev/null; then
|
|
40
|
+
echo "ACQUIRED by $task_id (reclaimed stale lock from $holder)"
|
|
41
|
+
exit 0
|
|
42
|
+
fi
|
|
43
|
+
# Lost the race: another task acquired it after we broke the stale lock.
|
|
44
|
+
holder=$(cut -d' ' -f1 "$LOCK_FILE" 2>/dev/null || echo "unknown")
|
|
45
|
+
acquired_at=$(cut -d' ' -f2 "$LOCK_FILE" 2>/dev/null || echo "unknown")
|
|
46
|
+
lock_pid=$(awk '{print $3}' "$LOCK_FILE" 2>/dev/null || echo "-")
|
|
47
|
+
fi
|
|
30
48
|
echo "HELD by $holder since $acquired_at (pid $lock_pid)"
|
|
31
49
|
exit 1
|
|
32
50
|
fi
|
package/lib/orphan-sweep.sh
CHANGED
|
@@ -1,19 +1,33 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# orphan-sweep.sh — find and clean orphaned worktrees
|
|
2
|
+
# orphan-sweep.sh — find and clean orphaned worktrees, stale merge locks,
|
|
3
|
+
# and stale lifecycle pins
|
|
3
4
|
#
|
|
4
5
|
# Usage:
|
|
5
6
|
# orphan-sweep.sh report — list orphans (read-only for worktrees);
|
|
6
7
|
# stale locks with dead PIDs are released
|
|
7
|
-
# orphan-sweep.sh clean — remove safe-to-clean orphans (merged branches only)
|
|
8
|
-
#
|
|
8
|
+
# orphan-sweep.sh clean — remove safe-to-clean orphans (merged branches only),
|
|
9
|
+
# release stale merge locks, and remove pins whose
|
|
10
|
+
# task has no running session
|
|
9
11
|
#
|
|
10
12
|
# Active-run knowledge is injected by the caller, never fetched here:
|
|
11
13
|
# CREW_ACTIVE_TASKS — space-separated task IDs with a running session
|
|
12
14
|
# CREW_ACTIVE_TASKS_FILE — path to a file with one task ID per line
|
|
13
15
|
# (blank lines and '#' comments are ignored)
|
|
14
|
-
# Both inputs feed one active set, matched exactly against worktree dir names
|
|
15
|
-
#
|
|
16
|
-
#
|
|
16
|
+
# Both inputs feed one active set, matched exactly against worktree dir names
|
|
17
|
+
# and pin dir names. Both inputs must contain full task UUIDs (lowercase
|
|
18
|
+
# 8-4-4-4-12 hex, e.g. 7dbe861d-cddd-479f-87e9-9423539c08a3): clean mode
|
|
19
|
+
# validates every entry against that format and exits 2 with a BLOCKED line
|
|
20
|
+
# on any malformed entry, because a truncated or reformatted ID would never
|
|
21
|
+
# match exactly and would silently defeat the never-touch-active-runs
|
|
22
|
+
# promise.
|
|
23
|
+
# A task in the active set is never touched: its worktree, any merge lock
|
|
24
|
+
# it holds, and its lifecycle pin are skipped regardless of merge status,
|
|
25
|
+
# lock age, or PID liveness.
|
|
26
|
+
#
|
|
27
|
+
# Lifecycle pins live on persistent disk ($CREW_HOME/.pins/<task>) so a VM
|
|
28
|
+
# reboot (which wipes tmpfs /tmp) can't strand a run mid-flight. A pin whose
|
|
29
|
+
# task has no running session is safe to remove: any new run re-pins
|
|
30
|
+
# idempotently (mkdir -p + cp) before use.
|
|
17
31
|
#
|
|
18
32
|
# Fail closed: clean mode without either input refuses to remove anything
|
|
19
33
|
# and exits 2. Report mode without either input still runs read-only but
|
|
@@ -61,6 +75,22 @@ task_is_active() {
|
|
|
61
75
|
esac
|
|
62
76
|
}
|
|
63
77
|
|
|
78
|
+
# Fail closed on malformed active-run IDs: the guard below matches entries
|
|
79
|
+
# exactly against full-UUID directory names, branch names, and merge-lock
|
|
80
|
+
# holders. A short/truncated/reformatted ID would never match and would
|
|
81
|
+
# silently defeat the never-touch-active-runs promise. No prefix or fuzzy
|
|
82
|
+
# matching — ambiguous prefixes could match multiple runs; refusing is
|
|
83
|
+
# the correct behavior.
|
|
84
|
+
UUID_RE='^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
|
85
|
+
if [ "$cmd" = "clean" ] && [ "$have_active_data" -eq 1 ]; then
|
|
86
|
+
for entry in $ACTIVE_TASKS; do
|
|
87
|
+
if [[ ! "$entry" =~ $UUID_RE ]]; then
|
|
88
|
+
echo "BLOCKED: CREW_ACTIVE_TASKS entry '$entry' is not a full task UUID (expected lowercase 8-4-4-4-12 hex) — refusing to clean"
|
|
89
|
+
exit 2
|
|
90
|
+
fi
|
|
91
|
+
done
|
|
92
|
+
fi
|
|
93
|
+
|
|
64
94
|
if [ "$cmd" = "clean" ] && [ "$have_active_data" -eq 0 ]; then
|
|
65
95
|
echo "BLOCKED: clean mode requires the active-run list (set CREW_ACTIVE_TASKS or CREW_ACTIVE_TASKS_FILE) — refusing to remove anything"
|
|
66
96
|
exit 2
|
|
@@ -157,8 +187,41 @@ if [ -d "$WORKTREE_DIR" ]; then
|
|
|
157
187
|
done
|
|
158
188
|
fi
|
|
159
189
|
|
|
190
|
+
# --- Reap stale lifecycle pins ---
|
|
191
|
+
# Pins live on persistent disk ($CREW_HOME/.pins/<task>) so a reboot can't
|
|
192
|
+
# strand a run; pins for tasks with no running session are garbage.
|
|
193
|
+
PINS_DIR="${CREW_HOME:-$HOME/workspace/.jarvis}/.pins"
|
|
194
|
+
if [ -d "$PINS_DIR" ]; then
|
|
195
|
+
for pin in "$PINS_DIR"/*/; do
|
|
196
|
+
[ -d "$pin" ] || continue
|
|
197
|
+
task_id=$(basename "$pin")
|
|
198
|
+
|
|
199
|
+
# Skip hidden dirs
|
|
200
|
+
[[ "$task_id" == .* ]] && continue
|
|
201
|
+
|
|
202
|
+
found=1
|
|
203
|
+
|
|
204
|
+
# Active runs are never touched.
|
|
205
|
+
if task_is_active "$task_id"; then
|
|
206
|
+
echo "ACTIVE_PIN: $task_id — dashboard shows a running session, kept"
|
|
207
|
+
continue
|
|
208
|
+
fi
|
|
209
|
+
|
|
210
|
+
echo "STALE_PIN: $task_id — no running session, safe to remove"
|
|
211
|
+
if [ "$cmd" = "clean" ]; then
|
|
212
|
+
rm -rf "$pin"
|
|
213
|
+
if [ ! -d "$pin" ]; then
|
|
214
|
+
echo " → removed"
|
|
215
|
+
else
|
|
216
|
+
echo " → FAILED: pin still present after rm, left in place"
|
|
217
|
+
any_failed=1
|
|
218
|
+
fi
|
|
219
|
+
fi
|
|
220
|
+
done
|
|
221
|
+
fi
|
|
222
|
+
|
|
160
223
|
if [ "$found" -eq 0 ]; then
|
|
161
|
-
echo "CLEAN: no orphans, no stale locks"
|
|
224
|
+
echo "CLEAN: no orphans, no stale locks, no stale pins"
|
|
162
225
|
fi
|
|
163
226
|
|
|
164
227
|
if [ "$any_failed" -eq 1 ]; then
|
package/lib/publish-npm.sh
CHANGED
|
@@ -84,23 +84,30 @@ if [ "$ALREADY_PUBLISHED" = "0" ]; then
|
|
|
84
84
|
|| fail "read-base" "could not read version from HEAD:package.json"
|
|
85
85
|
echo "PUBLISH_BASE=$OLD"
|
|
86
86
|
|
|
87
|
-
# 8. Write TARGET_VERSION into package.json (`version` field only)
|
|
88
|
-
# preserving
|
|
89
|
-
#
|
|
90
|
-
#
|
|
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)
|
|
91
95
|
PKG_JSON="$REPO_PATH/package.json"
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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"
|
|
99
105
|
echo "PUBLISH_DIFF:"
|
|
100
106
|
git -C "$REPO_PATH" diff -- package.json
|
|
101
107
|
ADD_DEL="$(git -C "$REPO_PATH" diff --numstat -- package.json | tr '\t' ' ')"
|
|
102
108
|
[ "$ADD_DEL" = "1 1 package.json" ] \
|
|
103
109
|
|| fail "version-diff" "expected a single-line version change, got: $ADD_DEL"
|
|
110
|
+
# STEP-8-END
|
|
104
111
|
|
|
105
112
|
# 9. Commit only if changed (retried run leaves the commit in place).
|
|
106
113
|
if git -C "$REPO_PATH" diff --quiet -- package.json; then
|
|
@@ -143,11 +150,28 @@ if [ "$ALREADY_PUBLISHED" = "0" ]; then
|
|
|
143
150
|
rm -f "$TGZ"
|
|
144
151
|
|
|
145
152
|
# 12. Verify: ground truth is the registry, not any agent's summary.
|
|
146
|
-
|
|
147
|
-
|
|
153
|
+
# The registry is eventually consistent: a publish followed by an
|
|
154
|
+
# immediate read can observe the pre-publish version (canary 5a027278
|
|
155
|
+
# hit a stale read replica ~6s after publish). A single stale read must
|
|
156
|
+
# never park a landed publish, so this block retries with backoff and
|
|
157
|
+
# client cache-busting instead of failing on the first mismatch.
|
|
158
|
+
# STEP-12-ANCHOR: publish verification (retry-tolerant)
|
|
159
|
+
VERIFY_ATTEMPTS="${VERIFY_ATTEMPTS:-12}"
|
|
160
|
+
VERIFY_SLEEP_SECS="${VERIFY_SLEEP_SECS:-10}"
|
|
161
|
+
REG=""
|
|
162
|
+
for attempt in $(seq 1 "$VERIFY_ATTEMPTS"); do
|
|
163
|
+
# --prefer-online busts npm's client-side packument cache; the retry
|
|
164
|
+
# loop absorbs server-side replica lag. A non-zero exit is a miss,
|
|
165
|
+
# not an immediate failure.
|
|
166
|
+
REG="$(npm view "$PKG" version --prefer-online 2>/dev/null || true)"
|
|
167
|
+
[ "$REG" = "$TARGET_VERSION" ] && break
|
|
168
|
+
echo "PUBLISH_VERIFY_RETRY=$attempt registry=$REG"
|
|
169
|
+
[ "$attempt" -lt "$VERIFY_ATTEMPTS" ] && sleep "$VERIFY_SLEEP_SECS"
|
|
170
|
+
done
|
|
148
171
|
[ "$REG" = "$TARGET_VERSION" ] \
|
|
149
|
-
|| fail "verify" "registry=$REG"
|
|
172
|
+
|| fail "verify" "registry=$REG after $VERIFY_ATTEMPTS attempts"
|
|
150
173
|
echo "PUBLISH_VERIFIED=$TARGET_VERSION"
|
|
174
|
+
# STEP-12-END
|
|
151
175
|
fi
|
|
152
176
|
|
|
153
177
|
# 13. Push the version-bump commit (idempotent: no-op if already pushed).
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# test-merge-lock.sh — regression tests for merge-lock.sh
|
|
3
|
+
#
|
|
4
|
+
# Self-contained: uses a temp CREW_REPO and drives lib/merge-lock.sh through
|
|
5
|
+
# acquire/release/status. Tests the self-healing acquire: a lock held by a
|
|
6
|
+
# dead PID is reclaimed automatically; a lock held by a live PID reports HELD.
|
|
7
|
+
# Exits 0 only if every case passes.
|
|
8
|
+
|
|
9
|
+
set -uo pipefail
|
|
10
|
+
|
|
11
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
12
|
+
LOCK="$SCRIPT_DIR/merge-lock.sh"
|
|
13
|
+
|
|
14
|
+
pass=0
|
|
15
|
+
fail=0
|
|
16
|
+
ok() { echo "PASS: $1"; pass=$((pass + 1)); }
|
|
17
|
+
no() { echo "FAIL: $1"; fail=$((fail + 1)); }
|
|
18
|
+
|
|
19
|
+
TMPBASE="$(mktemp -d)"
|
|
20
|
+
trap 'rm -rf "$TMPBASE"' EXIT
|
|
21
|
+
|
|
22
|
+
export CREW_REPO="$TMPBASE/repo"
|
|
23
|
+
mkdir -p "$CREW_REPO/.worktrees"
|
|
24
|
+
|
|
25
|
+
# --- Case 1: acquire on unlocked succeeds ---
|
|
26
|
+
# Use $$ (this shell's PID) which is definitely alive.
|
|
27
|
+
out=$("$LOCK" acquire task-1 $$ 2>&1); code=$?
|
|
28
|
+
[ "$code" -eq 0 ] && [[ "$out" == *"ACQUIRED by task-1"* ]] && ok "1: acquire on unlocked succeeds" || no "1: exit $code, out: $out"
|
|
29
|
+
|
|
30
|
+
# --- Case 2: second acquire with live PID reports HELD ---
|
|
31
|
+
out=$("$LOCK" acquire task-2 99999 2>&1); code=$?
|
|
32
|
+
[ "$code" -eq 1 ] && [[ "$out" == *"HELD by task-1"* ]] && ok "2: acquire with live holder PID reports HELD" || no "2: exit $code, out: $out"
|
|
33
|
+
|
|
34
|
+
# --- Case 3: release by owner succeeds ---
|
|
35
|
+
out=$("$LOCK" release task-1 2>&1); code=$?
|
|
36
|
+
[ "$code" -eq 0 ] && [[ "$out" == *"RELEASED by task-1"* ]] && ok "3: release by owner succeeds" || no "3: exit $code, out: $out"
|
|
37
|
+
|
|
38
|
+
# --- Case 4: status reports UNLOCKED after release ---
|
|
39
|
+
out=$("$LOCK" status 2>&1); code=$?
|
|
40
|
+
[ "$code" -eq 0 ] && [[ "$out" == "UNLOCKED" ]] && ok "4: status UNLOCKED after release" || no "4: exit $code, out: $out"
|
|
41
|
+
|
|
42
|
+
# --- Case 5: stale lock (dead PID) is reclaimed on acquire ---
|
|
43
|
+
# Write a lock file with a PID that is certainly dead (99999999).
|
|
44
|
+
echo "stale-task 2026-09-10T06:21:15Z 99999999" > "$CREW_REPO/.worktrees/.merge-lock"
|
|
45
|
+
out=$("$LOCK" acquire task-3 22222 2>&1); code=$?
|
|
46
|
+
[ "$code" -eq 0 ] && [[ "$out" == *"reclaimed stale lock from stale-task"* ]] && ok "5: stale lock (dead PID) reclaimed on acquire" || no "5: exit $code, out: $out"
|
|
47
|
+
|
|
48
|
+
# --- Case 6: after reclaim, the new holder owns the lock ---
|
|
49
|
+
out=$("$LOCK" status 2>&1); code=$?
|
|
50
|
+
[ "$code" -eq 0 ] && [[ "$out" == *"LOCKED by task-3"* ]] && ok "6: reclaimed lock owned by new task" || no "6: exit $code, out: $out"
|
|
51
|
+
|
|
52
|
+
# --- Case 7: release by non-owner fails ---
|
|
53
|
+
out=$("$LOCK" release wrong-task 2>&1); code=$?
|
|
54
|
+
[ "$code" -eq 1 ] && [[ "$out" == *"ERROR: lock held by task-3"* ]] && ok "7: release by non-owner fails" || no "7: exit $code, out: $out"
|
|
55
|
+
|
|
56
|
+
# --- Case 8: release by owner cleans up ---
|
|
57
|
+
out=$("$LOCK" release task-3 2>&1); code=$?
|
|
58
|
+
[ "$code" -eq 0 ] && [ ! -f "$CREW_REPO/.worktrees/.merge-lock" ] && ok "8: release removes lock file" || no "8: exit $code, out: $out"
|
|
59
|
+
|
|
60
|
+
# --- Case 9: lock with missing PID field is treated as stale ---
|
|
61
|
+
# A lock file with "-" as PID (or empty) should be reclaimable.
|
|
62
|
+
echo "old-task 2026-09-10T06:21:15Z -" > "$CREW_REPO/.worktrees/.merge-lock"
|
|
63
|
+
out=$("$LOCK" acquire task-4 33333 2>&1); code=$?
|
|
64
|
+
[ "$code" -eq 0 ] && [[ "$out" == *"reclaimed stale lock from old-task"* ]] && ok "9: lock with '-' PID reclaimed as stale" || no "9: exit $code, out: $out"
|
|
65
|
+
"$LOCK" release task-4 >/dev/null 2>&1
|
|
66
|
+
|
|
67
|
+
echo ""
|
|
68
|
+
echo "merge-lock: $pass passed, $fail failed"
|
|
69
|
+
[ "$fail" -eq 0 ]
|
package/lib/test-orphan-sweep.sh
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
# Self-contained: builds throwaway git fixtures in a temp dir and drives
|
|
5
5
|
# lib/orphan-sweep.sh through the CREW_ACTIVE_TASKS / CREW_ACTIVE_TASKS_FILE
|
|
6
6
|
# seam (no network, no dashboard). Exits 0 only if every case passes.
|
|
7
|
+
#
|
|
8
|
+
# Format contract: clean mode validates every active-set entry is a full
|
|
9
|
+
# lowercase task UUID and exits 2 with a BLOCKED line otherwise (cases 9, 10).
|
|
10
|
+
# Report mode has no format expectation and keeps current behavior.
|
|
7
11
|
|
|
8
12
|
set -uo pipefail
|
|
9
13
|
|
|
@@ -43,14 +47,20 @@ merged_worktree() {
|
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
# --- Case 1: live run protected (the incident's exact case) ---
|
|
46
|
-
#
|
|
50
|
+
# Active-set fixtures must be full lowercase UUIDs: clean mode fails closed
|
|
51
|
+
# (exit 2) on any active-set entry that is not one, and exact matching means
|
|
52
|
+
# the worktree dir / lock holder / pin dir names use the same UUIDs.
|
|
53
|
+
UUID_ACTIVE="8e951fb5-cddd-479f-87e9-9423539c08a3" # live-run guard + incident case
|
|
54
|
+
UUID_LOCK="bc5a1654-cddd-479f-87e9-9423539c08a3" # merge-lock holder
|
|
55
|
+
UUID_PIN="1c4b0e7b-cddd-479f-87e9-9423539c08a3" # lifecycle pin owner
|
|
56
|
+
SHORT_ACTIVE="8e951fb5" # 8-char prefix of UUID_ACTIVE
|
|
47
57
|
new_fixture case1
|
|
48
|
-
git worktree add -q .worktrees
|
|
49
|
-
out=$(CREW_ACTIVE_TASKS="
|
|
58
|
+
git worktree add -q ".worktrees/$UUID_ACTIVE" -b "task/$UUID_ACTIVE" >/dev/null
|
|
59
|
+
out=$(CREW_ACTIVE_TASKS="$UUID_ACTIVE" "$SWEEP" clean 2>&1); code=$?
|
|
50
60
|
[ "$code" -eq 0 ] && ok "1: clean exits 0 with only an active run present" || no "1: exit $code, want 0"
|
|
51
|
-
[ -d .worktrees
|
|
52
|
-
git show-ref --verify --quiet refs/heads/task
|
|
53
|
-
echo "$out" | grep -q "ACTIVE:
|
|
61
|
+
[ -d ".worktrees/$UUID_ACTIVE" ] && ok "1: active worktree dir preserved" || no "1: active worktree dir removed"
|
|
62
|
+
git show-ref --verify --quiet "refs/heads/task/$UUID_ACTIVE" && ok "1: active branch preserved" || no "1: active branch deleted"
|
|
63
|
+
echo "$out" | grep -q "ACTIVE: $UUID_ACTIVE" && ok "1: ACTIVE line printed" || no "1: no ACTIVE line in output"
|
|
54
64
|
|
|
55
65
|
# --- Case 2: dead run cleaned ---
|
|
56
66
|
new_fixture case2
|
|
@@ -89,8 +99,8 @@ out=$(CREW_ACTIVE_TASKS="" "$SWEEP" clean 2>&1); code=$?
|
|
|
89
99
|
echo "$out" | grep -q "released (dead PID)" && ok "5a: release reported" || no "5a: no release line in output"
|
|
90
100
|
[ "$code" -eq 0 ] && ok "5a: exit 0" || no "5a: exit $code, want 0"
|
|
91
101
|
|
|
92
|
-
printf '
|
|
93
|
-
out=$(CREW_ACTIVE_TASKS="
|
|
102
|
+
printf '%s 2020-01-01T00:00:00Z 999999\n' "$UUID_LOCK" > .worktrees/.merge-lock
|
|
103
|
+
out=$(CREW_ACTIVE_TASKS="$UUID_LOCK" "$SWEEP" clean 2>&1); code=$?
|
|
94
104
|
[ -f .worktrees/.merge-lock ] && ok "5b: active holder's lock not released" || no "5b: active holder's lock was released"
|
|
95
105
|
echo "$out" | grep -q "ACTIVE_LOCK" && ok "5b: ACTIVE_LOCK line printed" || no "5b: no ACTIVE_LOCK line in output"
|
|
96
106
|
[ "$code" -eq 0 ] && ok "5b: exit 0" || no "5b: exit $code, want 0"
|
|
@@ -104,6 +114,62 @@ echo "$out" | grep -q "NOTE: no active-run data provided; ACTIVE checks skipped"
|
|
|
104
114
|
[ -d .worktrees/T7 ] && ok "6: report mode removes nothing" || no "6: report mode removed a worktree"
|
|
105
115
|
echo "$out" | grep -q "MERGED: T7" && ok "6: MERGED line still listed" || no "6: MERGED line missing from report"
|
|
106
116
|
|
|
117
|
+
# --- Case 7: lifecycle pins — active kept, stale reaped ---
|
|
118
|
+
new_fixture case7
|
|
119
|
+
export CREW_HOME="$TMPBASE/case7home"
|
|
120
|
+
mkdir -p "$CREW_HOME/.pins/$UUID_PIN" "$CREW_HOME/.pins/STALEPIN"
|
|
121
|
+
echo x > "$CREW_HOME/.pins/$UUID_PIN/worktree-lifecycle.sh"
|
|
122
|
+
echo x > "$CREW_HOME/.pins/STALEPIN/worktree-lifecycle.sh"
|
|
123
|
+
out=$(CREW_ACTIVE_TASKS="$UUID_PIN" "$SWEEP" clean 2>&1); code=$?
|
|
124
|
+
[ "$code" -eq 0 ] && ok "7: clean exits 0 with pins present" || no "7: exit $code, want 0"
|
|
125
|
+
[ -d "$CREW_HOME/.pins/$UUID_PIN" ] && ok "7: active task's pin kept" || no "7: active task's pin removed"
|
|
126
|
+
[ ! -d "$CREW_HOME/.pins/STALEPIN" ] && ok "7: stale pin removed" || no "7: stale pin still present"
|
|
127
|
+
echo "$out" | grep -q "ACTIVE_PIN: $UUID_PIN" && ok "7: ACTIVE_PIN line printed" || no "7: no ACTIVE_PIN line in output"
|
|
128
|
+
echo "$out" | grep -q "STALE_PIN: STALEPIN" && ok "7: STALE_PIN line printed" || no "7: no STALE_PIN line in output"
|
|
129
|
+
|
|
130
|
+
# --- Case 8: pins — report mode removes nothing; clean without active data fails closed ---
|
|
131
|
+
new_fixture case8
|
|
132
|
+
export CREW_HOME="$TMPBASE/case8home"
|
|
133
|
+
mkdir -p "$CREW_HOME/.pins/R1"
|
|
134
|
+
out=$(CREW_ACTIVE_TASKS="" "$SWEEP" report 2>&1); code=$?
|
|
135
|
+
[ "$code" -eq 0 ] && ok "8a: report exits 0" || no "8a: exit $code, want 0"
|
|
136
|
+
[ -d "$CREW_HOME/.pins/R1" ] && ok "8a: report mode removes no pins" || no "8a: report mode removed a pin"
|
|
137
|
+
echo "$out" | grep -q "STALE_PIN: R1" && ok "8a: STALE_PIN listed in report" || no "8a: STALE_PIN missing from report"
|
|
138
|
+
|
|
139
|
+
out=$(env -u CREW_ACTIVE_TASKS -u CREW_ACTIVE_TASKS_FILE "$SWEEP" clean 2>&1); code=$?
|
|
140
|
+
[ "$code" -eq 2 ] && ok "8b: clean without active data exits 2" || no "8b: exit $code, want 2"
|
|
141
|
+
[ -d "$CREW_HOME/.pins/R1" ] && ok "8b: pin left alone when BLOCKED" || no "8b: pin removed despite BLOCKED"
|
|
142
|
+
|
|
143
|
+
# --- Case 9: fail closed on a short ID in CREW_ACTIVE_TASKS (the incident) ---
|
|
144
|
+
# The sweep worker once passed 8-char prefixes; the guard would never match
|
|
145
|
+
# them exactly, so clean must refuse instead of silently defeating the
|
|
146
|
+
# active-run guard.
|
|
147
|
+
new_fixture case9
|
|
148
|
+
export CREW_HOME="$TMPBASE/case9home"
|
|
149
|
+
merged_worktree "$UUID_ACTIVE"
|
|
150
|
+
mkdir -p "$CREW_HOME/.pins/$UUID_ACTIVE"
|
|
151
|
+
out=$(CREW_ACTIVE_TASKS="$SHORT_ACTIVE" "$SWEEP" clean 2>&1); code=$?
|
|
152
|
+
[ "$code" -eq 2 ] && ok "9: short ID in CREW_ACTIVE_TASKS exits 2" || no "9: exit $code, want 2"
|
|
153
|
+
echo "$out" | grep -q "BLOCKED" && ok "9: BLOCKED line printed" || no "9: no BLOCKED line in output"
|
|
154
|
+
echo "$out" | grep -q "$SHORT_ACTIVE" && ok "9: BLOCKED line names the bad entry" || no "9: BLOCKED line does not name the entry"
|
|
155
|
+
[ -d ".worktrees/$UUID_ACTIVE" ] && ok "9: worktree preserved" || no "9: worktree removed despite BLOCKED"
|
|
156
|
+
git show-ref --verify --quiet "refs/heads/task/$UUID_ACTIVE" && ok "9: branch preserved" || no "9: branch deleted despite BLOCKED"
|
|
157
|
+
[ -d "$CREW_HOME/.pins/$UUID_ACTIVE" ] && ok "9: pin preserved" || no "9: pin removed despite BLOCKED"
|
|
158
|
+
|
|
159
|
+
# --- Case 10: fail closed on a short ID in CREW_ACTIVE_TASKS_FILE ---
|
|
160
|
+
new_fixture case10
|
|
161
|
+
export CREW_HOME="$TMPBASE/case10home"
|
|
162
|
+
merged_worktree "$UUID_ACTIVE"
|
|
163
|
+
mkdir -p "$CREW_HOME/.pins/$UUID_ACTIVE"
|
|
164
|
+
printf '%s\n' "$SHORT_ACTIVE" > "$TMPBASE/case10-active.txt"
|
|
165
|
+
out=$(env -u CREW_ACTIVE_TASKS CREW_ACTIVE_TASKS_FILE="$TMPBASE/case10-active.txt" "$SWEEP" clean 2>&1); code=$?
|
|
166
|
+
[ "$code" -eq 2 ] && ok "10: short ID in CREW_ACTIVE_TASKS_FILE exits 2" || no "10: exit $code, want 2"
|
|
167
|
+
echo "$out" | grep -q "BLOCKED" && ok "10: BLOCKED line printed" || no "10: no BLOCKED line in output"
|
|
168
|
+
echo "$out" | grep -q "$SHORT_ACTIVE" && ok "10: BLOCKED line names the bad entry" || no "10: no BLOCKED line naming the entry"
|
|
169
|
+
[ -d ".worktrees/$UUID_ACTIVE" ] && ok "10: worktree preserved" || no "10: worktree removed despite BLOCKED"
|
|
170
|
+
git show-ref --verify --quiet "refs/heads/task/$UUID_ACTIVE" && ok "10: branch preserved" || no "10: branch deleted despite BLOCKED"
|
|
171
|
+
[ -d "$CREW_HOME/.pins/$UUID_ACTIVE" ] && ok "10: pin preserved" || no "10: pin removed despite BLOCKED"
|
|
172
|
+
|
|
107
173
|
echo ""
|
|
108
174
|
echo "== $pass passed, $fail failed =="
|
|
109
175
|
[ "$fail" -eq 0 ]
|