muse-crew 0.4.2 → 0.4.4

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
@@ -0,0 +1,174 @@
1
+ #!/usr/bin/env bash
2
+ # publish-npm.sh — deterministic npm publish for Muse Crew.
3
+ #
4
+ # Takes the entire publish decision as input; the Publish agent makes none.
5
+ # TARGET_VERSION is authoritative — computed by the workflow, never here.
6
+ # Idempotent on retry/resume: if this task already published the target
7
+ # version (version-bump commit + registry both present), the mutation steps
8
+ # are skipped and the run goes straight to push (idempotent) + finalize.
9
+ #
10
+ # Env:
11
+ # TASK_ID task id (idempotent commit message + merge-lock ops)
12
+ # REPO_PATH repo checkout (Publish runs here, not in a worktree)
13
+ # PKG npm package name (default: muse-crew)
14
+ # TARGET_VERSION version to publish (workflow-computed, authoritative)
15
+ # CREW_HOME crew instance home (accepted for uniformity; unused here)
16
+ # LIFECYCLE pinned worktree-lifecycle.sh
17
+ # RELEASE_SCRIPT crew-release.sh (stable path)
18
+ # NPM_PUBLISH_PY publish helper (default: ~/workspace/skills/npm/bin/npm-publish.py)
19
+ # DRY_RUN=1 print markers and exit before any mutation
20
+ #
21
+ # Output markers (PUBLISH_* lines). On any failure: print
22
+ # PUBLISH_FAILED=<short-reason> plus one detail line, exit 1.
23
+ # On success: print the markers below, exit 0. The final marker is
24
+ # PUBLISH_COMPLETE=<version> — the Publish agent pastes it verbatim into its
25
+ # summary as the script's completion marker.
26
+
27
+ set -euo pipefail
28
+
29
+ PKG="${PKG:-muse-crew}"
30
+ NPM_PUBLISH_PY="${NPM_PUBLISH_PY:-$HOME/workspace/skills/npm/bin/npm-publish.py}"
31
+
32
+ fail() { # fail <reason> [detail]
33
+ echo "PUBLISH_FAILED=$1"
34
+ if [ $# -ge 2 ]; then
35
+ echo "$2"
36
+ fi
37
+ exit 1
38
+ }
39
+
40
+ # 1-2. Validate env, then print the target.
41
+ [ -n "${TASK_ID:-}" ] || fail "env" "TASK_ID is required"
42
+ [ -n "${REPO_PATH:-}" ] || fail "env" "REPO_PATH is required"
43
+ [ -n "${TARGET_VERSION:-}" ] || fail "env" "TARGET_VERSION is required"
44
+ echo "PUBLISH_TARGET=$TARGET_VERSION"
45
+ git -C "$REPO_PATH" rev-parse --git-dir >/dev/null 2>&1 \
46
+ || fail "env" "REPO_PATH is not a git checkout: $REPO_PATH"
47
+
48
+ # 3. Dry run: markers only, no lock, no deploy, no mutation.
49
+ if [ "${DRY_RUN:-0}" = "1" ]; then
50
+ echo "PUBLISH_DRY_RUN=1"
51
+ exit 0
52
+ fi
53
+
54
+ # 4. Idempotency: this task's version-bump commit is HEAD and the registry
55
+ # already serves the target version — a prior Publish finished. Skip the
56
+ # mutation steps; push (idempotent) and finalize still run.
57
+ ALREADY_PUBLISHED=0
58
+ HEAD_MSG="$(git -C "$REPO_PATH" log -1 --format=%s)"
59
+ REG="$(npm view "$PKG" version 2>/dev/null || true)"
60
+ if { [ "$HEAD_MSG" = "release: $PKG@$TARGET_VERSION ($TASK_ID)" ] \
61
+ || [ "$HEAD_MSG" = "release: $PKG@$TARGET_VERSION" ]; } \
62
+ && [ "$REG" = "$TARGET_VERSION" ]; then
63
+ ALREADY_PUBLISHED=1
64
+ echo "PUBLISHED_ALREADY=$TARGET_VERSION"
65
+ echo "PUBLISH_VERIFIED=$TARGET_VERSION"
66
+ fi
67
+
68
+ if [ "$ALREADY_PUBLISHED" = "0" ]; then
69
+ # 5. Refresh the merge lock (held from Integrate through post-deploy).
70
+ LOCK_OUT="$(CREW_REPO="$REPO_PATH" bash "${LIFECYCLE:?LIFECYCLE is required}" refresh-lock "$TASK_ID" 2>&1)" \
71
+ || fail "lock-refresh" "$LOCK_OUT"
72
+
73
+ # 6. Install/activate the immutable release.
74
+ REL_OUT="$(bash "${RELEASE_SCRIPT:?RELEASE_SCRIPT is required}" deploy "$REPO_PATH" 2>&1)" \
75
+ || fail "release-deploy" "$REL_OUT"
76
+ case "$REL_OUT" in
77
+ *INSTALLED*ACTIVATED*|*EXISTS*ACTIVATED*) ;;
78
+ *) fail "release-deploy" "unexpected output: $REL_OUT" ;;
79
+ esac
80
+
81
+ # 7. Read the old version (audit only — TARGET_VERSION stays authoritative).
82
+ OLD="$(git -C "$REPO_PATH" show "HEAD:package.json" \
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"
115
+ else
116
+ git -C "$REPO_PATH" add package.json \
117
+ || fail "commit" "git add package.json failed"
118
+ git -C "$REPO_PATH" commit -m "release: $PKG@$TARGET_VERSION ($TASK_ID)" >/dev/null \
119
+ || fail "commit" "git commit failed"
120
+ echo "PUBLISH_COMMIT=$(git -C "$REPO_PATH" rev-parse HEAD)"
121
+ fi
122
+
123
+ # 10. Pack and sanity-check the tarball's embedded version.
124
+ cd "$REPO_PATH" || fail "pack" "cannot cd to $REPO_PATH"
125
+ npm pack >/dev/null 2>&1 || fail "pack" "npm pack failed"
126
+ TGZ="$REPO_PATH/$PKG-$TARGET_VERSION.tgz"
127
+ [ -f "$TGZ" ] || fail "pack" "expected tarball $TGZ not produced"
128
+ TGZ_VER="$(tar -xzOf "$TGZ" package/package.json \
129
+ | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).version")" \
130
+ || fail "pack-version-mismatch" "could not read version from tarball $TGZ"
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
+ ;;
148
+ esac
149
+ fi
150
+ rm -f "$TGZ"
151
+
152
+ # 12. Verify: ground truth is the registry, not any agent's summary.
153
+ REG="$(npm view "$PKG" version 2>/dev/null)" \
154
+ || fail "verify" "npm view $PKG failed"
155
+ [ "$REG" = "$TARGET_VERSION" ] \
156
+ || fail "verify" "registry=$REG"
157
+ echo "PUBLISH_VERIFIED=$TARGET_VERSION"
158
+ fi
159
+
160
+ # 13. Push the version-bump commit (idempotent: no-op if already pushed).
161
+ PUSH_OUT="$(git -C "$REPO_PATH" push origin main 2>&1)" \
162
+ || fail "push" "$PUSH_OUT"
163
+ echo "PUBLISH_PUSHED=1"
164
+
165
+ # 14. Finalize: releases the merge lock and cleans up the worktree.
166
+ FIN_OUT="$(CREW_REPO="$REPO_PATH" bash "$LIFECYCLE" post-deploy "$TASK_ID" 2>&1)" \
167
+ || fail "post-deploy" "$FIN_OUT"
168
+ case "$FIN_OUT" in
169
+ *DEPLOYED*) echo "PUBLISH_FINALIZED=1" ;;
170
+ *) fail "post-deploy" "missing DEPLOYED marker: $FIN_OUT" ;;
171
+ esac
172
+
173
+ # Final marker: the full publish path completed.
174
+ echo "PUBLISH_COMPLETE=$TARGET_VERSION"
@@ -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 ]