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.
@@ -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())
@@ -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
- # /tmp/crew-lib-* pins lifecycle scripts per run.
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
@@ -1,19 +1,33 @@
1
1
  #!/usr/bin/env bash
2
- # orphan-sweep.sh — find and clean orphaned worktrees + stale merge locks
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
- # and release stale merge locks
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
- # A task in the active set is never touched: its worktree and any merge lock
16
- # it holds are skipped regardless of merge status, lock age, or PID liveness.
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
@@ -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 the file's existing formatting (2-space indent + trailing
89
- # newline). The change must be exactly one line anything else means the
90
- # rewrite did not preserve the file.
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
- PKG_JSON="$PKG_JSON" TARGET_VERSION="$TARGET_VERSION" node -e '
93
- const fs = require("fs");
94
- const f = process.env.PKG_JSON;
95
- const j = JSON.parse(fs.readFileSync(f, "utf8"));
96
- j.version = process.env.TARGET_VERSION;
97
- fs.writeFileSync(f, JSON.stringify(j, null, 2) + "\n");
98
- ' || fail "version-write" "node could not write $TARGET_VERSION into 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"
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
- REG="$(npm view "$PKG" version 2>/dev/null)" \
147
- || fail "verify" "npm view $PKG failed"
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 ]
@@ -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
- # T1's branch tip equals main (no commits "merged" by equality), clean tree.
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/T1 -b task/T1 >/dev/null
49
- out=$(CREW_ACTIVE_TASKS="T1" "$SWEEP" clean 2>&1); code=$?
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/T1 ] && ok "1: active worktree dir preserved" || no "1: active worktree dir removed"
52
- git show-ref --verify --quiet refs/heads/task/T1 && ok "1: active branch preserved" || no "1: active branch deleted"
53
- echo "$out" | grep -q "ACTIVE: T1" && ok "1: ACTIVE line printed" || no "1: no ACTIVE line in output"
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 'T6 2020-01-01T00:00:00Z 999999\n' > .worktrees/.merge-lock
93
- out=$(CREW_ACTIVE_TASKS="T6" "$SWEEP" clean 2>&1); code=$?
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 ]