muse-crew 0.4.5 → 0.4.7

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,192 @@
1
+ #!/usr/bin/env bash
2
+ # test-publish-skip.sh — regression test for the graceful no-lock publish
3
+ # skip (park 2026-09-11: empty-diff Integrate takes no merge lock, but
4
+ # Publish ran refresh-lock unconditionally and failed closed).
5
+ #
6
+ # Covers:
7
+ # - worktree-lifecycle.sh lock-status: UNLOCKED (exit 0) with no lock;
8
+ # LOCKED by <holder> since <ts> (pid <pid>) when held (exit 0).
9
+ # - fake empty-diff integrate -> lock-status reports UNLOCKED (the lock
10
+ # is intentionally never taken on the MERGED_EMPTY path).
11
+ # - the shipped step-5 block of publish-npm.sh (extracted by anchor):
12
+ # * UNLOCKED -> NO_LOCK_HELD=1, PUBLISH_SKIPPED=no-lock-held, exit 0
13
+ # * LOCKED (self) -> refresh path runs, NO_LOCK_HELD=0, exit 0
14
+ # * lock-status query failure -> fail "lock-status", exit 1 (fail-closed)
15
+ # - structural: the step-5 anchor sits inside the ALREADY_PUBLISHED=0
16
+ # branch (a resumed run whose post-deploy released the lock prints
17
+ # nothing), and the mutation steps are gated on NO_LOCK_HELD=0.
18
+ #
19
+ # Anchors: `# STEP-5-ANCHOR` ... `# STEP-5-END` in lib/publish-npm.sh.
20
+
21
+ set -uo pipefail
22
+
23
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
24
+ LIFECYCLE="$SCRIPT_DIR/worktree-lifecycle.sh"
25
+ MERGE_LOCK="$SCRIPT_DIR/merge-lock.sh"
26
+ PUBLISH="$SCRIPT_DIR/publish-npm.sh"
27
+
28
+ pass=0
29
+ fail_count=0
30
+ ok() { echo "PASS: $1"; pass=$((pass + 1)); }
31
+ no() { echo "FAIL: $1"; fail_count=$((fail_count + 1)); }
32
+
33
+ TMPBASE="$(mktemp -d)"
34
+ trap 'rm -rf "$TMPBASE"' EXIT
35
+
36
+ # --- Scratch repo ---
37
+ SCRATCH="$TMPBASE/repo"
38
+ git init -q -b main "$SCRATCH"
39
+ git -C "$SCRATCH" config user.email "test@crew"
40
+ git -C "$SCRATCH" config user.name "crew-test"
41
+ echo ".worktrees/" > "$SCRATCH/.gitignore"
42
+ echo x > "$SCRATCH/f.txt"
43
+ git -C "$SCRATCH" add -A && git -C "$SCRATCH" commit -qm init
44
+
45
+ export CREW_REPO="$SCRATCH" CREW_LIB="$SCRIPT_DIR"
46
+
47
+ TASK="skiptest-task-1"
48
+
49
+ # --- lock-status with no lock held ---
50
+ OUT="$(bash "$LIFECYCLE" lock-status "$TASK" 2>&1)"
51
+ CODE=$?
52
+ if [ $CODE -eq 0 ] && [ "$OUT" = "UNLOCKED" ]; then
53
+ ok "lock-status reports UNLOCKED with no lock held (exit 0)"
54
+ else
55
+ no "lock-status UNLOCKED: code=$CODE out='$OUT'"
56
+ fi
57
+
58
+ # --- lock-status with a lock held ---
59
+ bash "$MERGE_LOCK" acquire "$TASK" "$$" >/dev/null
60
+ OUT="$(bash "$LIFECYCLE" lock-status "$TASK" 2>&1)"
61
+ CODE=$?
62
+ if [ $CODE -eq 0 ] && printf '%s' "$OUT" | grep -Eq "^LOCKED by $TASK since .+ \(pid [0-9]+\)$"; then
63
+ ok "lock-status reports LOCKED by <holder> since <ts> (pid <pid>) (exit 0)"
64
+ else
65
+ no "lock-status LOCKED: code=$CODE out='$OUT'"
66
+ fi
67
+ bash "$MERGE_LOCK" release "$TASK" >/dev/null
68
+
69
+ # --- fake empty-diff integrate: MERGED_EMPTY takes no lock ---
70
+ bash "$LIFECYCLE" prepare "$TASK" >/dev/null 2>&1
71
+ INTEG="$(bash "$LIFECYCLE" integrate "$TASK" "merge: $TASK" 2>&1)"
72
+ if [[ "$INTEG" == MERGED_EMPTY* ]]; then
73
+ ok "fake empty-diff integrate takes the MERGED_EMPTY branch"
74
+ else
75
+ no "empty-diff integrate: '$INTEG'"
76
+ fi
77
+ OUT="$(bash "$LIFECYCLE" lock-status "$TASK" 2>&1)"
78
+ if [ "$OUT" = "UNLOCKED" ]; then
79
+ ok "after MERGED_EMPTY integrate the lock is still UNLOCKED (never taken)"
80
+ else
81
+ no "lock held after empty-diff integrate: '$OUT'"
82
+ fi
83
+ bash "$LIFECYCLE" cleanup "$TASK" >/dev/null 2>&1
84
+
85
+ # --- Extract the shipped step-5 block by anchor ---
86
+ BLOCK="$TMPBASE/step5.sh"
87
+ sed -n '/# STEP-5-ANCHOR/,/# STEP-5-END/p' "$PUBLISH" > "$BLOCK"
88
+ if grep -q 'lock-status' "$BLOCK" && grep -q 'PUBLISH_SKIPPED=no-lock-held' "$BLOCK" \
89
+ && grep -q 'fail "lock-status"' "$BLOCK" && grep -q 'NO_LOCK_HELD=1' "$BLOCK"; then
90
+ ok "extracted step-5 block from publish-npm.sh"
91
+ else
92
+ no "step-5 extraction failed (anchors missing)"
93
+ fi
94
+
95
+ # Runner: provides fail(), env, sources the extracted block, then reports
96
+ # NO_LOCK_HELD and the exit code. Never calls fail = never parks.
97
+ run_block() { # run_block <lock_setup: none|held|broken>
98
+ local mode="$1"
99
+ local run="$TMPBASE/run-$mode"
100
+ mkdir -p "$run"
101
+ case "$mode" in
102
+ none) : ;;
103
+ held) bash "$MERGE_LOCK" acquire "$TASK" "$$" >/dev/null ;;
104
+ broken) : ;;
105
+ esac
106
+ (
107
+ set -euo pipefail
108
+ export CREW_REPO="$SCRATCH" TASK_ID="$TASK" REPO_PATH="$SCRATCH"
109
+ if [ "$mode" = "broken" ]; then
110
+ export LIFECYCLE="/nonexistent/lifecycle.sh"
111
+ else
112
+ export LIFECYCLE="$LIFECYCLE"
113
+ fi
114
+ fail() { echo "PUBLISH_FAILED=$1"; [ $# -ge 2 ] && echo "$2"; exit 1; }
115
+ NO_LOCK_HELD=0
116
+ # shellcheck disable=SC1090
117
+ source "$BLOCK"
118
+ echo "RESULT_NO_LOCK_HELD=$NO_LOCK_HELD"
119
+ ) > "$run/out" 2>&1
120
+ local code=$?
121
+ echo "$code" > "$run/code"
122
+ case "$mode" in
123
+ held) bash "$MERGE_LOCK" release "$TASK" >/dev/null 2>&1 || true ;;
124
+ esac
125
+ }
126
+
127
+ # --- UNLOCKED: skip gracefully, never park ---
128
+ run_block none
129
+ CODE="$(cat "$TMPBASE/run-none/code")"
130
+ OUT="$(cat "$TMPBASE/run-none/out")"
131
+ if [ "$CODE" -eq 0 ] && [[ "$OUT" == *"PUBLISH_SKIPPED=no-lock-held"* ]] \
132
+ && [[ "$OUT" == *"RESULT_NO_LOCK_HELD=1"* ]] && [[ "$OUT" != *"PUBLISH_FAILED"* ]]; then
133
+ ok "step-5 on UNLOCKED: skips gracefully (NO_LOCK_HELD=1, marker printed, exit 0)"
134
+ else
135
+ no "step-5 UNLOCKED: code=$CODE out='$OUT'"
136
+ fi
137
+
138
+ # --- LOCKED by self: refresh path runs, no skip ---
139
+ run_block held
140
+ CODE="$(cat "$TMPBASE/run-held/code")"
141
+ OUT="$(cat "$TMPBASE/run-held/out")"
142
+ if [ "$CODE" -eq 0 ] && [[ "$OUT" != *"PUBLISH_SKIPPED"* ]] \
143
+ && [[ "$OUT" == *"RESULT_NO_LOCK_HELD=0"* ]] && [[ "$OUT" != *"PUBLISH_FAILED"* ]]; then
144
+ ok "step-5 on LOCKED (self): refresh path runs, no skip marker, exit 0"
145
+ else
146
+ no "step-5 LOCKED: code=$CODE out='$OUT'"
147
+ fi
148
+
149
+ # --- lock-status query failure: fail closed ---
150
+ run_block broken
151
+ CODE="$(cat "$TMPBASE/run-broken/code")"
152
+ OUT="$(cat "$TMPBASE/run-broken/out")"
153
+ if [ "$CODE" -ne 0 ] && [[ "$OUT" == *"PUBLISH_FAILED=lock-status"* ]]; then
154
+ ok "step-5 on lock-status failure: fail-closed PUBLISH_FAILED=lock-status"
155
+ else
156
+ no "step-5 broken query: code=$CODE out='$OUT'"
157
+ fi
158
+
159
+ # --- Structural: the gate sits inside the ALREADY_PUBLISHED=0 branch ---
160
+ # (a resumed run whose post-deploy already released the lock must print
161
+ # nothing — its PUBLISHED_ALREADY/PUBLISH_VERIFIED markers say it all).
162
+ AP_LINE="$(grep -n 'if \[ "\$ALREADY_PUBLISHED" = "0" \]; then' "$PUBLISH" | head -1 | cut -d: -f1)"
163
+ ANCHOR_LINE="$(grep -n '# STEP-5-ANCHOR' "$PUBLISH" | head -1 | cut -d: -f1)"
164
+ END_LINE="$(grep -n '# STEP-5-END' "$PUBLISH" | head -1 | cut -d: -f1)"
165
+ if [ -n "$AP_LINE" ] && [ -n "$ANCHOR_LINE" ] && [ -n "$END_LINE" ] \
166
+ && [ "$AP_LINE" -lt "$ANCHOR_LINE" ] && [ "$ANCHOR_LINE" -lt "$END_LINE" ]; then
167
+ ok "step-5 anchor sits inside the ALREADY_PUBLISHED=0 branch"
168
+ else
169
+ no "step-5 anchor placement wrong (ap=$AP_LINE anchor=$ANCHOR_LINE end=$END_LINE)"
170
+ fi
171
+
172
+ # --- Structural: mutation steps are gated on NO_LOCK_HELD=0 ---
173
+ if grep -q 'if \[ "\$NO_LOCK_HELD" = "0" \]; then' "$PUBLISH" \
174
+ && grep -q 'fi # NO_LOCK_HELD=0' "$PUBLISH"; then
175
+ ok "mutation path is gated on NO_LOCK_HELD=0"
176
+ else
177
+ no "NO_LOCK_HELD gate missing in publish-npm.sh"
178
+ fi
179
+
180
+ # --- Structural: PUBLISH_COMPLETE is omitted on the skip path ---
181
+ if grep -q 'PUBLISH_COMPLETE is deliberately not printed' "$PUBLISH"; then
182
+ ok "skip path omits PUBLISH_COMPLETE (no false publish claim)"
183
+ else
184
+ no "PUBLISH_COMPLETE skip-path handling missing"
185
+ fi
186
+
187
+ bash -n "$LIFECYCLE" && ok "worktree-lifecycle.sh syntax OK" || no "worktree-lifecycle.sh syntax"
188
+ bash -n "$PUBLISH" && ok "publish-npm.sh syntax OK" || no "publish-npm.sh syntax"
189
+
190
+ echo ""
191
+ echo "$pass passed, $fail_count failed"
192
+ exit $((fail_count > 0))
@@ -19,6 +19,7 @@
19
19
  # cleanup <task_id> — remove worktree + branch (no lock)
20
20
  # status <task_id> — report worktree state
21
21
  # refresh-lock <task_id> — refresh merge lock timestamp (resets staleness clock)
22
+ # lock-status <task_id> — report merge lock state: UNLOCKED or LOCKED by <holder> since <ts> (pid <pid>)
22
23
  # sweep [report|clean] — find/clean orphans + stale locks
23
24
  #
24
25
  # Exit codes:
@@ -202,13 +203,39 @@ cmd_integrate() {
202
203
  return 0
203
204
  fi
204
205
 
205
- # Acquire merge lock
206
+ # Acquire the merge lock (serializes version assignment across concurrent
207
+ # runs). The holder is the opaque task+run identity from WORKFLOW_RUN_ID —
208
+ # never a PID (bug 2fc8f52f: short-lived agent PIDs made every concurrent
209
+ # acquire take the stale path). Another task mid Integrate/Publish is
210
+ # normal under simultaneity>1: bounded backoff (30s sleeps, 10min cap,
211
+ # overridable via MERGE_LOCK_RETRY_SECS / MERGE_LOCK_RETRY_CAP_SECS), then
212
+ # give up with LOCK_HELD so the workflow records the failure with a reason
213
+ # (dispatcher retries under its cap, parks after the cap).
214
+ local holder="${WORKFLOW_RUN_ID:-$task_id}"
215
+ local retry_secs="${MERGE_LOCK_RETRY_SECS:-30}"
216
+ local retry_cap="${MERGE_LOCK_RETRY_CAP_SECS:-600}"
217
+ local waited=0
206
218
  local lock_result
207
- if ! lock_result=$("$MERGE_LOCK" acquire "$task_id" "$$"); then
208
- echo "LOCK_HELD: $lock_result"
209
- exit 2
210
- fi
211
- echo "$lock_result"
219
+ while true; do
220
+ if lock_result=$("$MERGE_LOCK" acquire "$task_id" "$holder"); then
221
+ echo "$lock_result"
222
+ break
223
+ fi
224
+ case "$lock_result" in
225
+ HELD*)
226
+ if [ "$waited" -ge "$retry_cap" ]; then
227
+ echo "LOCK_HELD: $lock_result (waited ${waited}s of ${retry_cap}s backoff)"
228
+ exit 2
229
+ fi
230
+ sleep "$retry_secs"
231
+ waited=$((waited + retry_secs))
232
+ ;;
233
+ *)
234
+ echo "LOCK_ERROR: $lock_result"
235
+ exit 2
236
+ ;;
237
+ esac
238
+ done
212
239
 
213
240
  # Merge
214
241
  if ! git merge --no-ff "$branch" -m "$commit_msg" 2>&1; then
@@ -338,7 +365,17 @@ cmd_sweep() {
338
365
  cmd_refresh_lock() {
339
366
  local task_id="$1"
340
367
  validate_task_id "$task_id"
341
- "$MERGE_LOCK" refresh "$task_id" "$$"
368
+ "$MERGE_LOCK" refresh "$task_id"
369
+ }
370
+
371
+ cmd_lock_status() {
372
+ local task_id="$1"
373
+ validate_task_id "$task_id"
374
+ # Passthrough to the lock's own status readout. Scripts never call
375
+ # merge-lock.sh directly — this keeps the seam. Always exits 0:
376
+ # UNLOCKED is explicit state (an empty-diff Integrate intentionally
377
+ # takes no lock), never an error.
378
+ "$MERGE_LOCK" status
342
379
  }
343
380
 
344
381
  # --- dispatch ---
@@ -355,10 +392,11 @@ case "$cmd" in
355
392
  cleanup) cmd_cleanup "${1:?task_id required}" ;;
356
393
  status) cmd_status "${1:?task_id required}" ;;
357
394
  refresh-lock) cmd_refresh_lock "${1:?task_id required}" ;;
395
+ lock-status) cmd_lock_status "${1:?task_id required}" ;;
358
396
  sweep) cmd_sweep "${1:-report}" ;;
359
397
  *)
360
398
  echo "Usage: worktree-lifecycle.sh <command> [args]"
361
- echo "Commands: validate, prepare, inspect, integrate, post-deploy, cleanup, status, refresh-lock, sweep"
399
+ echo "Commands: validate, prepare, inspect, integrate, post-deploy, cleanup, status, refresh-lock, lock-status, sweep"
362
400
  exit 1
363
401
  ;;
364
402
  esac
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muse-crew",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
4
4
  "description": "Opinionated orchestration for Muse \u2014 workflows, identities, and tooling for autonomous software development.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -510,6 +510,12 @@ if (missingInitialPins.length > 0) {
510
510
  }
511
511
  log("Lifecycle scripts pinned to " + RUN_LIB);
512
512
 
513
+ // Merge-lock holder identity (bug 2fc8f52f): the opaque task+run identity
514
+ // minted at this run's first claim (never a PID — short-lived agent PIDs
515
+ // made every concurrent acquire take the stale path). Set exactly once on
516
+ // the first claim; stable for the rest of the run's lifetime.
517
+ let lockHolder = taskId;
518
+
513
519
  while (i < STEPS.length) {
514
520
  const step = STEPS[i];
515
521
  const isFirstClaim = (i === startStepIndex && totalReworkCount === 0);
@@ -693,6 +699,13 @@ while (i < STEPS.length) {
693
699
  activeSessionId = claimResult.session_id;
694
700
  }
695
701
 
702
+ // Mint the merge-lock holder identity once: task+session of this run's
703
+ // first claim. The task ID alone cannot distinguish two runs of the same
704
+ // task (re-dispatch after park), and a PID is not a valid holder at all.
705
+ if (lockHolder === taskId && activeSessionId) {
706
+ lockHolder = taskId + "/" + activeSessionId;
707
+ }
708
+
696
709
  // ── Capture: baseline evidence for experiential tasks ─────────────
697
710
  // Hazel's QA capture pass runs right after Triage, before Map, for tasks
698
711
  // Sage flagged experiential. The capture itself is parent-driven (the
@@ -870,11 +883,11 @@ while (i < STEPS.length) {
870
883
 
871
884
  } else if (step.name === "Integrate") {
872
885
  instructions = "Merge the approved task branch into main.\n\n" +
873
- "Run: "+ LIFECYCLE_ENV + " integrate " + taskId + " \"merge: fix: " + safeTitle + "\"\n\n" +
886
+ "Run: "+ LIFECYCLE_ENV + "WORKFLOW_RUN_ID=" + lockHolder + " integrate " + taskId + " \"merge: fix: " + safeTitle + "\"\n\n" +
874
887
  "Read the output:\n" +
875
888
  "- If it contains MERGED, integration succeeded. Report the merged commit hash.\n" +
876
889
  "- If it contains MERGED_EMPTY, the branch had no commits ahead of main (a runtime-state deliverable, declared by Build as repo_diff: none). Integration succeeded vacuously: the merge lock was NOT taken and there is no new commit. Report 'merged empty: no repo changes — deliverable was runtime state', then end your report with exactly this line: VERDICT: PASS. SKIP STEP 2 (push): there is no new commit to push.\n" +
877
- "- If it contains LOCK_HELD, another task holds the merge lock (mid Integrate/Publish). Report 'merge lock held', then end your report with exactly this line: VERDICT: FAIL.\n" +
890
+ "- If it contains LOCK_HELD, another task holds the merge lock (mid Integrate/Publish) and the 10-minute bounded backoff is exhausted. Report 'merge lock held after bounded backoff', then end your report with exactly this line: VERDICT: FAIL.\n" +
878
891
  "- If it contains CONFLICT, the plain merge failed — the merge was aborted, main is clean, and your task still holds the merge lock. Do NOT fail yet. Resolve it:\n" +
879
892
  "RESOLUTION:\n" +
880
893
  "R1. Refresh the merge lock FIRST (a long resolution must not silently lose the lock to the orphan sweep): "+ LIFECYCLE_ENV + " refresh-lock " + taskId + ". Create a scratch worktree WITH A NEW BRANCH (main is already checked out in the repo checkout, so git forbids checking it out a second time): cd " + REPO_PATH + " && git worktree add -b resolve/" + taskId + " /tmp/crew-resolve-" + taskId + " main. Reproduce the conflict in the scratch worktree: cd /tmp/crew-resolve-" + taskId + " && git merge " + TASK_BRANCH + ". This reproduces the exact conflict (main has not moved — the lock was held throughout). The task branch " + TASK_BRANCH + " is never modified.\n" +
@@ -903,13 +916,17 @@ while (i < STEPS.length) {
903
916
  instructions = "Publish the npm package by running exactly ONE command — the deterministic publish script.\n" +
904
917
  "The release decision is already made and recorded: release: yes, version_bump: " + publishTarget.scope +
905
918
  ", target version " + publishTarget.target + " (computed as " + publishTarget.base + " + " + publishTarget.scope +
906
- " → " + publishTarget.target + " by the workflow, not by you). There is no decision to make and no skip path.\n\n" +
919
+ " → " + publishTarget.target + " by the workflow, not by you). There is no decision to make; the script itself skips gracefully when no merge lock is held.\n\n" +
907
920
  "Run exactly this command and no other publish-related commands:\n" +
908
921
  "TASK_ID=" + taskId + " REPO_PATH=" + REPO_PATH + " PKG=muse-crew TARGET_VERSION=" + publishTarget.target +
909
922
  " CREW_HOME=" + crewHome + " LIFECYCLE=" + LIFECYCLE + " RELEASE_SCRIPT=" + RELEASE_SCRIPT +
910
923
  " bash " + PUBLISH_NPM + "\n\n" +
911
924
  "Do NOT run npm, npm pack, npm publish, or the python publish script yourself. Do NOT compare local and registry versions. Do NOT decide whether to publish.\n\n" +
912
925
  "If the command exits nonzero, put the script's PUBLISH_FAILED line in your report, then end your report with exactly this line: VERDICT: FAIL.\n" +
926
+ "If the script's output contains PUBLISH_SKIPPED=no-lock-held, the publish was skipped gracefully: Integrate reported MERGED_EMPTY (no commits ahead of main), so no merge lock was taken and there is nothing to ship. Paste the marker block verbatim into your report, then end your report with exactly these three lines, in this order — lowercase, no trailing period, do not rephrase:\n" +
927
+ "TARGET_VERSION=" + publishTarget.target + " computed as " + publishTarget.base + " + " + publishTarget.scope + " → " + publishTarget.target + "\n" +
928
+ "skipped: no-lock-held (empty-diff Integrate — nothing merged, nothing to ship)\n" +
929
+ "VERDICT: PASS\n\n" +
913
930
  "If it exits zero, paste the script's COMPLETE marker block verbatim into your report, then end your report with exactly these three lines, in this order — lowercase, no trailing period, do not rephrase:\n" +
914
931
  "TARGET_VERSION=" + publishTarget.target + " computed as " + publishTarget.base + " + " + publishTarget.scope + " → " + publishTarget.target + "\n" +
915
932
  "published: muse-crew@" + publishTarget.target + "\n" +
@@ -928,20 +945,34 @@ while (i < STEPS.length) {
928
945
  // getprovenance-vs-HEAD verification below stays as the final gate.
929
946
  var artifactPublish = null;
930
947
  var publishLockRefreshed = false;
948
+ var publishSkippedNoLock = false;
931
949
  try {
932
- // STEP 0 (mechanical): refresh the merge lock so a long build cannot
933
- // go stale mid-publish.
934
- var lockRefresh = await agent(
935
- "Run: "+ LIFECYCLE_ENV + " refresh-lock " + taskId + "\n" +
936
- "Return JSON { \"refreshed\": <true if the command exited zero, false otherwise>, \"output\": \"<trimmed stdout>\" } and nothing else.",
937
- { key: attemptKey("publish-lock-refresh-" + taskId, totalReworkCount), label: "Refreshing merge lock for publish",
938
- schema: { type: "object", properties: { refreshed: { type: "boolean" }, output: { type: "string" } }, required: ["refreshed"] } }
950
+ // STEP 0 (mechanical): read the merge-lock state explicitly never
951
+ // infer it from prose. An empty-diff Integrate (MERGED_EMPTY)
952
+ // intentionally takes no lock: nothing merged, nothing to ship. When
953
+ // no lock is held, Publish skips the publish path gracefully instead
954
+ // of failing closed on refresh-lock (park 2026-09-11).
955
+ var lockState = await agent(
956
+ "Run: "+ LIFECYCLE_ENV + " lock-status " + taskId + "\n" +
957
+ "If the output is exactly UNLOCKED, return JSON { \"state\": \"UNLOCKED\" } and run nothing else.\n" +
958
+ "Otherwise the lock is held: run "+ LIFECYCLE_ENV + " refresh-lock " + taskId + " (a long build must not silently lose the lock to the orphan sweep), then return JSON { \"state\": \"LOCKED\", \"refreshed\": <true if the refresh exited zero, false otherwise>, \"output\": \"<trimmed refresh stdout>\" } and nothing else.",
959
+ { key: attemptKey("publish-lock-refresh-" + taskId, totalReworkCount), label: "Reading merge lock state for publish",
960
+ schema: { type: "object", properties: { state: { type: "string" }, refreshed: { type: "boolean" }, output: { type: "string" } }, required: ["state"] } }
939
961
  );
940
- if (!lockRefresh.refreshed) {
941
- return await parkTask("Publish lock refresh failed: " + (lockRefresh.output || "no output") + ". Fail-closed — lock state unknown.");
962
+ if ((lockState.state || "").trim() === "UNLOCKED") {
963
+ publishSkippedNoLock = true;
964
+ log("Publish skipped for task " + taskId + ": no merge lock held (empty-diff Integrate) — nothing to ship");
965
+ } else {
966
+ if (!lockState.refreshed) {
967
+ return await parkTask("Publish lock refresh failed: " + (lockState.output || "no output") + ". Fail-closed — lock state unknown.");
968
+ }
969
+ publishLockRefreshed = true;
942
970
  }
943
- publishLockRefreshed = true;
944
- // STEP 1 (mechanical): trigger the rebuild with one narrow call. The
971
+ // STEP 1 (mechanical): trigger the rebuild with one narrow call.
972
+ // Skipped entirely when no lock was held nothing merged, nothing
973
+ // to ship.
974
+ if (!publishSkippedNoLock) {
975
+ // (below) the rebuild trigger, bounded poll, and provenance stamp
945
976
  // agent only makes the artifact_edit call and reports whether it was
946
977
  // accepted — no prose claim to trust. If the artifact tool namespace
947
978
  // is missing from this child it reports honestly and the workflow
@@ -962,14 +993,46 @@ while (i < STEPS.length) {
962
993
  }
963
994
  var publishFailure = null;
964
995
  if (rebuildTrigger.edit_started) {
965
- // STEP 1b (mechanical): bounded poll for build completion.
966
- var buildPoll = await agent(
967
- "Poll artifact_status for slug \"" + PUBLISH_SLUG + "\" until no build is running. Check every 30 seconds, up to 20 checks (10 minutes max).\n" +
968
- "Return JSON { \"build_done\": <true if no build is running within budget, false on timeout>, \"status\": \"<final status or timeout note>\" } and nothing else.",
969
- { key: attemptKey("publish-artifact-poll-" + taskId, totalReworkCount), label: "Waiting for artifact build to complete",
970
- schema: { type: "object", properties: { build_done: { type: "boolean" }, status: { type: "string" } }, required: ["build_done"] },
971
- timeoutMs: 660000 }
972
- );
996
+ // STEP 1b (mechanical): bounded poll for build completion, chunked so
997
+ // the merge-lock lease is refreshed before it can expire. The 600s
998
+ // lease is shorter than the worst-case 10-minute build poll, so the
999
+ // poll runs in three chunks (7 checks x 30s ~= 3.5 min each) with a
1000
+ // holder-only lease refresh between chunks. If a refresh fails, the
1001
+ // lock was lost: stop the run and park the task never continue to
1002
+ // a provenance stamp or version assignment without holding the lock.
1003
+ var buildPoll = null;
1004
+ for (var chunk = 1; chunk <= 3; chunk++) {
1005
+ if (chunk > 1) {
1006
+ var refreshPoll = await agent(
1007
+ "Refresh the merge lock for task " + taskId + ".\n" +
1008
+ "Run: " + LIFECYCLE_ENV + " refresh-lock " + taskId + "\n" +
1009
+ "If the output contains REFRESHED, return JSON { \"held\": true } and nothing else. Otherwise return JSON { \"held\": false, \"output\": \"<verbatim output>\" } and nothing else.",
1010
+ { key: attemptKey("publish-lock-refresh-poll-" + taskId + "-c" + chunk, totalReworkCount), label: "Refreshing merge lock during build poll",
1011
+ schema: { type: "object", properties: { held: { type: "boolean" }, output: { type: "string" } }, required: ["held"] },
1012
+ timeoutMs: 60000 }
1013
+ );
1014
+ if (!refreshPoll || refreshPoll.held !== true) {
1015
+ return await parkTask("Merge-lock lease lost during the artifact build poll (refresh before chunk " + chunk + " of 3 failed: " + ((refreshPoll && refreshPoll.output) || "no output") + "). Fail-closed: stopping before any provenance stamp or version assignment.");
1016
+ }
1017
+ }
1018
+ // Chunk 1 keeps the original stable key; later chunks use -c<N>
1019
+ // suffixed keys. All are attemptKey-scoped so rework passes stay
1020
+ // disjoint (replay-key contract).
1021
+ var pollKey = (chunk === 1)
1022
+ ? attemptKey("publish-artifact-poll-" + taskId, totalReworkCount)
1023
+ : attemptKey("publish-artifact-poll-" + taskId + "-c" + chunk, totalReworkCount);
1024
+ buildPoll = await agent(
1025
+ "Poll artifact_status for slug \"" + PUBLISH_SLUG + "\" until no build is running. Check every 30 seconds, up to 7 checks (3.5 minutes max).\n" +
1026
+ "Return JSON { \"build_done\": <true if no build is running within budget, false on timeout>, \"status\": \"<final status or timeout note>\" } and nothing else.",
1027
+ { key: pollKey, label: "Waiting for artifact build to complete (chunk " + chunk + " of 3)",
1028
+ schema: { type: "object", properties: { build_done: { type: "boolean" }, status: { type: "string" } }, required: ["build_done"] },
1029
+ timeoutMs: 270000 }
1030
+ );
1031
+ if (buildPoll && buildPoll.build_done) { break; }
1032
+ }
1033
+ if (!buildPoll || !buildPoll.build_done) {
1034
+ buildPoll = { build_done: false, status: (buildPoll && buildPoll.status) || "build still running after the 10.5-minute bounded poll" };
1035
+ }
973
1036
  if (buildPoll.build_done) {
974
1037
  // STEP 1c (mechanical): stamp provenance from workflow-computed values.
975
1038
  var provStamp = await agent(
@@ -993,25 +1056,34 @@ while (i < STEPS.length) {
993
1056
  } else {
994
1057
  publishFailure = "Artifact rebuild trigger failed: " + (rebuildTrigger.error || "artifact_edit not accepted") + ". The publish did not land.";
995
1058
  }
996
- // STEP 2 (mechanical, always once the lock was refreshed): post-deploy
1059
+ } // end: publishSkippedNoLock no rebuild, no stamp, nothing to ship
1060
+ // STEP 2 (mechanical, always — skip path included): post-deploy
997
1061
  // commits builder leftovers if any, removes the worktree, and releases
998
- // the merge lock — even when the publish itself failed, so a skipped
999
- // or failed publish can never leave the lock held (canary b5efd1b1).
1062
+ // the merge lock (forgiving when none is held) — even when the publish
1063
+ // itself failed, so a skipped or failed publish can never leave the
1064
+ // lock held (canary b5efd1b1).
1000
1065
  var postDeploy = await agent(
1001
1066
  "Run: "+ LIFECYCLE_ENV + " post-deploy " + taskId + "\n" +
1002
1067
  "Return JSON { \"deployed\": <true if the output contains DEPLOYED, false otherwise>, \"output\": \"<trimmed output>\" } and nothing else.",
1003
1068
  { key: attemptKey("publish-postdeploy-" + taskId, totalReworkCount), label: "Finalizing publish (post-deploy)",
1004
1069
  schema: { type: "object", properties: { deployed: { type: "boolean" }, output: { type: "string" } }, required: ["deployed"] } }
1005
1070
  );
1006
- if (publishFailure) {
1007
- return await parkTask(publishFailure + (postDeploy.deployed
1008
- ? " Post-deploy finalized cleanup."
1009
- : " Post-deploy also failed (" + (postDeploy.output || "no output") + ") — worktree and lock state unknown."));
1010
- }
1011
- if (!postDeploy.deployed) {
1012
- return await parkTask("Post-deploy failed after a stamped publish: " + (postDeploy.output || "no output") + ". The publish landed but worktree cleanup and lock release are unknown — human attention needed.");
1071
+ if (publishSkippedNoLock) {
1072
+ if (!postDeploy.deployed) {
1073
+ return await parkTask("Post-deploy failed after a skipped publish: " + (postDeploy.output || "no output") + ". Nothing was published; worktree cleanup and lock state unknown — human attention needed.");
1074
+ }
1075
+ log("Publish skipped cleanly for task " + taskId + " (no lock held) — post-deploy finalized cleanup");
1076
+ } else {
1077
+ if (publishFailure) {
1078
+ return await parkTask(publishFailure + (postDeploy.deployed
1079
+ ? " Post-deploy finalized cleanup."
1080
+ : " Post-deploy also failed (" + (postDeploy.output || "no output") + ") — worktree and lock state unknown."));
1081
+ }
1082
+ if (!postDeploy.deployed) {
1083
+ return await parkTask("Post-deploy failed after a stamped publish: " + (postDeploy.output || "no output") + ". The publish landed but worktree cleanup and lock release are unknown — human attention needed.");
1084
+ }
1085
+ log("Deterministic artifact publish completed for task " + taskId + ": provenance at " + artifactPublish.source_commit);
1013
1086
  }
1014
- log("Deterministic artifact publish completed for task " + taskId + ": provenance at " + artifactPublish.source_commit);
1015
1087
  } catch (pubErr) {
1016
1088
  // Best-effort cleanup: if the lock was refreshed, try to release it
1017
1089
  // before parking so the failure cannot wedge the next run.
@@ -1032,6 +1104,12 @@ while (i < STEPS.length) {
1032
1104
  // The work agent no longer performs the publish — it reports on the
1033
1105
  // mechanical outcome above. It must not rebuild or re-stamp: a second
1034
1106
  // artifact_edit would trigger a duplicate build.
1107
+ if (publishSkippedNoLock) {
1108
+ instructions = "Publish was skipped deterministically by the workflow before your step — do NOT call artifact_edit, artifact_status, setprovenance, or post-deploy yourself; doing so would disturb the finalized state.\n\n" +
1109
+ "Integrate reported MERGED_EMPTY (the task branch had no commits ahead of main), so no merge lock was taken and there is nothing to ship. The workflow finalized cleanup via post-deploy.\n\n" +
1110
+ "Write plain prose describing the skip, then on its own line: VERDICT: PASS\n" +
1111
+ "The VERDICT line must be the last line of your report.";
1112
+ } else {
1035
1113
  instructions = "Publish the merged code to the live artifact.\n\n" +
1036
1114
  "The publish was performed deterministically by the workflow before your step — do NOT call artifact_edit, artifact_status, setprovenance, or post-deploy yourself; doing so would trigger a duplicate build or disturb the finalized state. You perform no publish actions.\n\n" +
1037
1115
  "For the change summary, run: cd " + REPO_PATH + " && git log -1 --stat\n\n" +
@@ -1044,6 +1122,7 @@ while (i < STEPS.length) {
1044
1122
  "The repo push already happened in Integrate — do NOT push to git in this phase.\n\n" +
1045
1123
  "Write plain prose describing what was published, then on its own line: VERDICT: PASS\n" +
1046
1124
  "The VERDICT line must be the last line of your report.";
1125
+ }
1047
1126
  } else if (PUBLISH_TYPE === "vercel") {
1048
1127
  // vercel publish is not yet implemented — block without inventing behavior
1049
1128
  instructions = "The project's publish target is \"vercel\", which is not yet implemented.\n" +
@@ -1055,7 +1134,9 @@ while (i < STEPS.length) {
1055
1134
  // declared release: yes, QA verifies the registry actually moved. A silent
1056
1135
  // publish skip becomes a loud QA failure with evidence, not a pass.
1057
1136
  var npmPublishCheck = (PUBLISH_TYPE === "npm" && releaseDecision && releaseDecision.release === "yes")
1058
- ? "NPM PUBLISH CHECK: the accepted Build report declared release: yes, so this run's Publish phase must have published. Find this task's recorded Publish result: call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"getstate\", args: { \"events_limit\": 1 }, then find the session for this task_id with step \"Publish\" (status completed) in the returned sessions array and read its session notes (the Publish agent's summary — event history does NOT carry it).\n" +
1137
+ ? "NPM PUBLISH CHECK: the accepted Build report declared release: yes, so this run's Publish phase must have published — UNLESS it was skipped deterministically on an empty-diff Integrate. Find this task's recorded Publish result: call artifact_invoke_action on slug \"" + DASHBOARD_SLUG + "\", action \"getstate\", args: { \"events_limit\": 1 }, then find the session for this task_id with step \"Publish\" (status completed) in the returned sessions array and read its session notes (the Publish agent's summary — event history does NOT carry it).\n" +
1138
+ "CHECK THE SKIP PATH FIRST: if the notes contain a line matching skipped: no-lock-held, Publish was skipped deterministically — Integrate reported MERGED_EMPTY (no commits ahead of main), so no merge lock was taken and there was nothing to ship. Verify the notes contain that skip-marker line and do NOT contain a line matching published: muse-crew@. Do NOT run npm view and do NOT demand registry movement — nothing was supposed to ship. Report 'npm publish check: Publish skipped deterministically (empty-diff Integrate — nothing to ship)' and PASS this check.\n" +
1139
+ "Only when the notes contain no skip marker must the publish have landed — run the full verification below.\n" +
1059
1140
  "Extract the line matching TARGET_VERSION=<new-version> computed as <base> + <scope> → <new-version> (the workflow appends it to the Publish notes, so it is always present). If the line is missing, report 'npm publish verification failed: Publish notes did not carry the computed target version', then end your report with exactly this line: VERDICT: FAIL.\n" +
1060
1141
  "Verify the bump SCOPE: the <scope> in that line MUST equal the accepted version_bump scope \"" + releaseDecision.version_bump + "\" — if it differs, report the mismatch, then end your report with exactly this line: VERDICT: FAIL. Verify the ARITHMETIC: <base> + <scope> must equal <new-version> (patch increments the last segment only, e.g. 0.3.0 + patch → 0.3.1; minor increments the middle and resets the last to 0; major increments the first and resets the rest to 0) — if the math is wrong, report it, then end your report with exactly this line: VERDICT: FAIL.\n" +
1061
1142
  "Extract the published version from the notes line matching published: muse-crew@<version>. It MUST equal <new-version> from the TARGET_VERSION line. Then run: npm view muse-crew version. The registry version MUST equal <new-version>. If any of these checks fails, report 'npm publish verification failed: [details]', then end your report with exactly this line: VERDICT: FAIL.\n"
@@ -1250,8 +1331,21 @@ while (i < STEPS.length) {
1250
1331
  const status = passed ? "completed" : (step.name === "Integrate" || step.name === "Publish" ? "failed" : "rejected");
1251
1332
 
1252
1333
  // Deterministic publish verification: the agent cannot self-certify a publish.
1334
+ // Skip-aware (park 2026-09-11): when the deterministic publish script found
1335
+ // no merge lock held (empty-diff Integrate), it skips the publish path
1336
+ // gracefully and emits the machine-readable PUBLISH_SKIPPED=no-lock-held
1337
+ // marker. Verification is then vacuous — nothing was shipped, and the
1338
+ // registry must NOT have moved. The marker is script-emitted explicit state
1339
+ // (pasted verbatim per the Publish agent instructions), not agent prose; a
1340
+ // report without the marker still runs the full verification fail-closed.
1253
1341
  var publishVerified = false;
1342
+ var npmPublishSkipped = false;
1254
1343
  if (step.name === "Publish" && PUBLISH_TYPE === "npm" && publishTarget) {
1344
+ if (/^PUBLISH_SKIPPED=no-lock-held$/m.test(workerText || "")) {
1345
+ npmPublishSkipped = true;
1346
+ log("Publish skipped for task " + taskId + " (no merge lock held — empty-diff Integrate): publish verification vacuous, nothing was shipped");
1347
+ }
1348
+ if (!npmPublishSkipped) {
1255
1349
  try {
1256
1350
  var verifyResult = await agent(
1257
1351
  "Run: npm view muse-crew version 2>/dev/null. Return JSON { \"registry_version\": \"<output trimmed>\" } and nothing else.",
@@ -1268,6 +1362,7 @@ while (i < STEPS.length) {
1268
1362
  } catch (e) {
1269
1363
  return await parkTask("Publish verification failed: could not read the registry version (" + (e && e.message ? e.message : e) + "). Fail-closed.");
1270
1364
  }
1365
+ } // end: !npmPublishSkipped — a skipped publish has nothing to verify
1271
1366
  }
1272
1367
 
1273
1368
  // Artifact publish verification: the worker cannot self-certify a deploy.
@@ -1275,6 +1370,14 @@ while (i < STEPS.length) {
1275
1370
  // integrated commit. A stale or missing provenance means the publish did not
1276
1371
  // land — fail closed, do not trust the worker's prose.
1277
1372
  if (step.name === "Publish" && PUBLISH_TYPE === "artifact" && PUBLISH_SLUG) {
1373
+ // Skip-aware (park 2026-09-11): an empty-diff Integrate takes no merge
1374
+ // lock, and the deterministic publish path skips rebuild/stamp entirely —
1375
+ // there is no new provenance to compare against HEAD, so verification is
1376
+ // vacuous. publishSkippedNoLock is workflow-computed state from the
1377
+ // explicit lock-status read in STEP 0, not agent prose.
1378
+ if (publishSkippedNoLock) {
1379
+ log("Publish skipped for task " + taskId + " (no merge lock held — empty-diff Integrate): artifact verification vacuous, nothing was shipped");
1380
+ } else {
1278
1381
  try {
1279
1382
  var headResult = await agent(
1280
1383
  "Run: cd " + REPO_PATH + " && git rev-parse HEAD. Return JSON { \"head\": \"<output trimmed>\" } and nothing else.",
@@ -1298,6 +1401,7 @@ while (i < STEPS.length) {
1298
1401
  } catch (e) {
1299
1402
  return await parkTask("Publish verification failed: could not read artifact provenance (" + (e && e.message ? e.message : e) + "). Fail-closed.");
1300
1403
  }
1404
+ } // end: !publishSkippedNoLock — a skipped publish has nothing to verify
1301
1405
  }
1302
1406
 
1303
1407
  // Session notes. Machine-readable marker lines are extracted from the full
@@ -1306,9 +1410,13 @@ while (i < STEPS.length) {
1306
1410
  // reading TARGET_VERSION=) depend on them.
1307
1411
  let summary;
1308
1412
  var workerMarkers = extractMarkerLines(workerText);
1309
- if (step.name === "Publish" && PUBLISH_TYPE === "npm" && publishTarget && publishVerified) {
1413
+ if (step.name === "Publish" && PUBLISH_TYPE === "npm" && publishTarget && (publishVerified || npmPublishSkipped)) {
1414
+ // The skip path records the computed target and the explicit skip — never
1415
+ // "published:", which would be a false claim (nothing was shipped).
1310
1416
  const markerLines = "TARGET_VERSION=" + publishTarget.target + " computed as " + publishTarget.base + " + " + publishTarget.scope + " → " + publishTarget.target + "\n" +
1311
- "published: muse-crew@" + publishTarget.target;
1417
+ (npmPublishSkipped
1418
+ ? "skipped: no-lock-held (empty-diff Integrate — nothing merged, nothing to ship)"
1419
+ : "published: muse-crew@" + publishTarget.target);
1312
1420
  summary = (stepResult.summary || "Step completed").slice(0, 2000 - markerLines.length - workerMarkers.length - 2) + "\n" + markerLines + (workerMarkers ? "\n" + workerMarkers : "");
1313
1421
  } else {
1314
1422
  summary = (stepResult.summary || "Step completed").slice(0, 2000 - workerMarkers.length - 1) + (workerMarkers ? "\n" + workerMarkers : "");