muse-crew 0.4.4 → 0.4.6

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,157 @@
1
+ #!/usr/bin/env bash
2
+ # test-publish-verify.sh — regression test for the retry-tolerant step-12
3
+ # publish verification in publish-npm.sh (canary 5a027278).
4
+ #
5
+ # Self-contained: extracts the shipped step-12 block from lib/publish-npm.sh
6
+ # by anchor and runs it against a fake `npm` on PATH whose read replica
7
+ # lags — non-zero exits, then the old version, then the target version —
8
+ # and requires the block to converge on success. Also asserts fail-closed
9
+ # exhaustion when the replica never converges.
10
+ #
11
+ # Anchors: `# STEP-12-ANCHOR` ... `# STEP-12-END` in lib/publish-npm.sh.
12
+
13
+ set -uo pipefail
14
+
15
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
16
+ PUBLISH="$SCRIPT_DIR/publish-npm.sh"
17
+
18
+ PKG="muse-crew"
19
+ OLD_VERSION="9.9.8"
20
+ TARGET_VERSION="9.9.9"
21
+
22
+ pass=0
23
+ fail_count=0
24
+ ok() { echo "PASS: $1"; pass=$((pass + 1)); }
25
+ no() { echo "FAIL: $1"; fail_count=$((fail_count + 1)); }
26
+
27
+ TMPBASE="$(mktemp -d)"
28
+ trap 'rm -rf "$TMPBASE"' EXIT
29
+
30
+ # --- Extract the shipped step-12 block by anchor ---
31
+ BLOCK="$TMPBASE/step12.sh"
32
+ sed -n '/# STEP-12-ANCHOR/,/# STEP-12-END/p' "$PUBLISH" > "$BLOCK"
33
+ if grep -q 'PUBLISH_VERIFIED' "$BLOCK" && grep -q 'fail "verify"' "$BLOCK"; then
34
+ ok "extracted step-12 block from publish-npm.sh"
35
+ else
36
+ no "step-12 extraction failed (anchors missing)"
37
+ fi
38
+
39
+ # --- Runner: fake npm first in PATH, behavior baked in at generation time ---
40
+ FAKEBIN="$TMPBASE/fakebin"
41
+ mkdir -p "$FAKEBIN"
42
+
43
+ # write_fake_npm <behavior>
44
+ # laggy: exit 1 twice (npm failure), old version twice (stale replica),
45
+ # then the target version (replica converged)
46
+ # stale: old version forever (replica never converges)
47
+ write_fake_npm() {
48
+ case "$1" in
49
+ laggy) BEHAVIOR='
50
+ if [ "$n" -le 2 ]; then exit 1; fi
51
+ if [ "$n" -le 4 ]; then echo "'"$OLD_VERSION"'"; exit 0; fi
52
+ echo "'"$TARGET_VERSION"'"; exit 0' ;;
53
+ stale) BEHAVIOR='echo "'"$OLD_VERSION"'"; exit 0' ;;
54
+ *) echo "unknown behavior: $1" >&2; exit 1 ;;
55
+ esac
56
+ cat > "$FAKEBIN/npm" <<EOF
57
+ #!/usr/bin/env bash
58
+ # Records its own argv (for the cache-busting assertion) and its call count.
59
+ echo "\$*" >> "$TMPBASE/argv"
60
+ n=\$(cat "$TMPBASE/calls" 2>/dev/null || echo 0)
61
+ n=\$((n + 1))
62
+ echo "\$n" > "$TMPBASE/calls"
63
+ $BEHAVIOR
64
+ EOF
65
+ chmod +x "$FAKEBIN/npm"
66
+ }
67
+
68
+ # run_step12: runs the shipped block; env: PKG TARGET_VERSION
69
+ # VERIFY_ATTEMPTS VERIFY_SLEEP_SECS; returns the block's exit code and
70
+ # captures its stdout in $OUT.
71
+ run_step12() {
72
+ local runner="$TMPBASE/run-$RANDOM.sh"
73
+ {
74
+ echo 'fail() { echo "PUBLISH_FAILED=$1"; shift; echo "$1"; exit 1; }'
75
+ cat "$BLOCK"
76
+ } > "$runner"
77
+ chmod +x "$runner"
78
+ rm -f "$TMPBASE/calls" "$TMPBASE/argv"
79
+ OUT="$(PATH="$FAKEBIN:$PATH" \
80
+ PKG="$PKG" TARGET_VERSION="$TARGET_VERSION" \
81
+ VERIFY_ATTEMPTS="${VERIFY_ATTEMPTS:-12}" \
82
+ VERIFY_SLEEP_SECS="${VERIFY_SLEEP_SECS:-0}" \
83
+ bash "$runner" 2>&1)"
84
+ local code=$?
85
+ echo "$OUT"
86
+ return "$code"
87
+ }
88
+
89
+ # --- Case 1: lagging replica converges — block must succeed ---
90
+ write_fake_npm laggy
91
+ OUT="$(run_step12)"
92
+ CODE=$?
93
+ if [ "$CODE" -eq 0 ]; then
94
+ ok "laggy replica converges: block exits 0"
95
+ else
96
+ no "laggy replica converges: block exited $CODE (output: $OUT)"
97
+ fi
98
+ case "$OUT" in
99
+ *"PUBLISH_VERIFIED=$TARGET_VERSION"*)
100
+ ok "prints PUBLISH_VERIFIED=$TARGET_VERSION on convergence" ;;
101
+ *) no "missing PUBLISH_VERIFIED=$TARGET_VERSION (output: $OUT)" ;;
102
+ esac
103
+ case "$OUT" in
104
+ *"PUBLISH_FAILED"*) no "PUBLISH_FAILED printed despite convergence" ;;
105
+ *) ok "no PUBLISH_FAILED on convergence" ;;
106
+ esac
107
+ CALLS="$(cat "$TMPBASE/calls" 2>/dev/null || echo 0)"
108
+ if [ "$CALLS" = "5" ]; then
109
+ ok "converged after exactly 5 reads (2 failures, 2 stale, 1 good)"
110
+ else
111
+ no "expected 5 npm calls on convergence, saw $CALLS"
112
+ fi
113
+ RETRIES="$(grep -c 'PUBLISH_VERIFY_RETRY=' <<< "$OUT" || true)"
114
+ if [ "$RETRIES" = "4" ]; then
115
+ ok "prints a PUBLISH_VERIFY_RETRY progress line per miss"
116
+ else
117
+ no "expected 4 PUBLISH_VERIFY_RETRY lines, saw $RETRIES"
118
+ fi
119
+
120
+ # --- Case 2: replica never converges — fail closed with the verify marker ---
121
+ write_fake_npm stale
122
+ VERIFY_ATTEMPTS=3 OUT="$(run_step12)"
123
+ CODE=$?
124
+ if [ "$CODE" -ne 0 ]; then
125
+ ok "stale replica: block fails closed (non-zero exit)"
126
+ else
127
+ no "stale replica: block exited 0 despite persistent mismatch"
128
+ fi
129
+ case "$OUT" in
130
+ *"PUBLISH_FAILED=verify"*)
131
+ ok "exhaustion keeps the PUBLISH_FAILED=verify marker contract" ;;
132
+ *) no "missing PUBLISH_FAILED=verify (output: $OUT)" ;;
133
+ esac
134
+ case "$OUT" in
135
+ *"registry=$OLD_VERSION after 3 attempts"*)
136
+ ok "exhaustion detail names registry value and attempt count" ;;
137
+ *) no "missing exhaustion detail (output: $OUT)" ;;
138
+ esac
139
+ CALLS="$(cat "$TMPBASE/calls" 2>/dev/null || echo 0)"
140
+ if [ "$CALLS" = "3" ]; then
141
+ ok "bounded: stops after VERIFY_ATTEMPTS reads"
142
+ else
143
+ no "expected 3 npm calls on exhaustion, saw $CALLS"
144
+ fi
145
+
146
+ # --- Case 3: every read goes through --prefer-online (client cache-bust) ---
147
+ write_fake_npm laggy
148
+ run_step12 >/dev/null
149
+ if grep -q -- '--prefer-online' "$TMPBASE/argv"; then
150
+ ok "every npm view read passes --prefer-online"
151
+ else
152
+ no "npm view reads missing --prefer-online"
153
+ fi
154
+
155
+ echo ""
156
+ echo "$pass passed, $fail_count failed."
157
+ [ "$fail_count" -eq 0 ]
@@ -16,6 +16,11 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
16
16
  REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
17
17
  PUBLISH="$SCRIPT_DIR/publish-npm.sh"
18
18
 
19
+ # Target the next patch after the repo's current version. (A hardcoded target
20
+ # goes stale the moment the repo releases it: writing the version that's
21
+ # already there produces no diff and the preservation check fails closed.)
22
+ TARGET_VERSION="$(node -e 'const fs=require("fs");const v=JSON.parse(fs.readFileSync(process.argv[1],"utf8")).version.split(".");v[v.length-1]=String(Number(v[v.length-1])+1);console.log(v.join("."))' "$REPO_DIR/package.json")"
23
+
19
24
  pass=0
20
25
  fail_count=0
21
26
  ok() { echo "PASS: $1"; pass=$((pass + 1)); }
@@ -58,7 +63,7 @@ RUNNER="$TMPBASE/run.sh"
58
63
  } > "$RUNNER"
59
64
  chmod +x "$RUNNER"
60
65
 
61
- if REPO_PATH="$FIX" TARGET_VERSION="0.4.4" bash "$RUNNER" >/dev/null 2>&1; then
66
+ if REPO_PATH="$FIX" TARGET_VERSION="$TARGET_VERSION" bash "$RUNNER" >/dev/null 2>&1; then
62
67
  ok "shipped step-8 block exits 0 on escape-carrying package.json"
63
68
  else
64
69
  no "shipped step-8 block failed closed on escape-carrying package.json"
@@ -66,10 +71,10 @@ fi
66
71
 
67
72
  # --- Assertions on the result ---
68
73
  VER="$(node -p "JSON.parse(require('fs').readFileSync('$FIX/package.json','utf8')).version")"
69
- if [ "$VER" = "0.4.4" ]; then
70
- ok "package.json version is 0.4.4 after write"
74
+ if [ "$VER" = "$TARGET_VERSION" ]; then
75
+ ok "package.json version is $TARGET_VERSION after write"
71
76
  else
72
- no "package.json version is $VER, expected 0.4.4"
77
+ no "package.json version is $VER, expected $TARGET_VERSION"
73
78
  fi
74
79
 
75
80
  NUMSTAT="$(git -C "$FIX" diff --numstat -- package.json | tr '\t' ' ')"
@@ -202,13 +202,39 @@ cmd_integrate() {
202
202
  return 0
203
203
  fi
204
204
 
205
- # Acquire merge lock
205
+ # Acquire the merge lock (serializes version assignment across concurrent
206
+ # runs). The holder is the opaque task+run identity from WORKFLOW_RUN_ID —
207
+ # never a PID (bug 2fc8f52f: short-lived agent PIDs made every concurrent
208
+ # acquire take the stale path). Another task mid Integrate/Publish is
209
+ # normal under simultaneity>1: bounded backoff (30s sleeps, 10min cap,
210
+ # overridable via MERGE_LOCK_RETRY_SECS / MERGE_LOCK_RETRY_CAP_SECS), then
211
+ # give up with LOCK_HELD so the workflow records the failure with a reason
212
+ # (dispatcher retries under its cap, parks after the cap).
213
+ local holder="${WORKFLOW_RUN_ID:-$task_id}"
214
+ local retry_secs="${MERGE_LOCK_RETRY_SECS:-30}"
215
+ local retry_cap="${MERGE_LOCK_RETRY_CAP_SECS:-600}"
216
+ local waited=0
206
217
  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"
218
+ while true; do
219
+ if lock_result=$("$MERGE_LOCK" acquire "$task_id" "$holder"); then
220
+ echo "$lock_result"
221
+ break
222
+ fi
223
+ case "$lock_result" in
224
+ HELD*)
225
+ if [ "$waited" -ge "$retry_cap" ]; then
226
+ echo "LOCK_HELD: $lock_result (waited ${waited}s of ${retry_cap}s backoff)"
227
+ exit 2
228
+ fi
229
+ sleep "$retry_secs"
230
+ waited=$((waited + retry_secs))
231
+ ;;
232
+ *)
233
+ echo "LOCK_ERROR: $lock_result"
234
+ exit 2
235
+ ;;
236
+ esac
237
+ done
212
238
 
213
239
  # Merge
214
240
  if ! git merge --no-ff "$branch" -m "$commit_msg" 2>&1; then
@@ -338,7 +364,7 @@ cmd_sweep() {
338
364
  cmd_refresh_lock() {
339
365
  local task_id="$1"
340
366
  validate_task_id "$task_id"
341
- "$MERGE_LOCK" refresh "$task_id" "$$"
367
+ "$MERGE_LOCK" refresh "$task_id"
342
368
  }
343
369
 
344
370
  # --- dispatch ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muse-crew",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "description": "Opinionated orchestration for Muse \u2014 workflows, identities, and tooling for autonomous software development.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -36,6 +36,9 @@ const PUBLISH_NPM_SRC = crewHome + "/lib/publish-npm.sh";
36
36
  const PUBLISH_NPM = RUN_LIB + "/publish-npm.sh";
37
37
  const ORPHAN_SWEEP_SRC = crewHome + "/lib/orphan-sweep.sh";
38
38
  const ORPHAN_SWEEP = RUN_LIB + "/orphan-sweep.sh";
39
+ // The four basenames the pin step must materialize — asserted mechanically
40
+ // by workflow code from the verbatim listing, never from agent prose.
41
+ const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM, ORPHAN_SWEEP].map(function (p) { return p.split("/").pop(); });
39
42
 
40
43
  // Project config — passed by dispatcher, falls back to dashboard defaults
41
44
  const projectConfig = inputs.project_config || {};
@@ -172,6 +175,27 @@ function workRetryKey(stepName, reworkSuffix, attempt) {
172
175
  function attemptKey(base, reworkCount) {
173
176
  return base + (reworkCount > 0 ? "-r" + reworkCount : "");
174
177
  }
178
+ // pinLifecycle(key) — snapshot the lifecycle scripts into RUN_LIB and return
179
+ // the verbatim `ls -1` listing so WORKFLOW CODE asserts the four pinned
180
+ // basenames; the agent cannot self-certify. (The pin step was the one place
181
+ // the workflows trusted agent prose: task 24be1cd6 walked to Publish on an
182
+ // empty pin dir.) Byte-identical across standard/bugfix/chore — pinned by
183
+ // tests/pin-location.test.js.
184
+ function pinLifecycle(key) {
185
+ return agent(
186
+ "Snapshot lifecycle scripts for version pinning.\n" +
187
+ "Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && cp " + ORPHAN_SWEEP_SRC + " " + ORPHAN_SWEEP + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " " + ORPHAN_SWEEP + " && ls -1 " + RUN_LIB + "\n" +
188
+ "Return the verbatim output of the ls -1 command as { \"listing\": \"<verbatim output>\" } and nothing else.",
189
+ { key: key, label: "Pinning lifecycle scripts",
190
+ schema: { type: "object", properties: { listing: { type: "string" } }, required: ["listing"] } }
191
+ );
192
+ }
193
+ // parsePinListing(result) — basenames from a pin/verify listing.
194
+ // Byte-identical across standard/bugfix/chore — pinned by
195
+ // tests/pin-location.test.js.
196
+ function parsePinListing(result) {
197
+ return (result && result.listing ? result.listing : "").split("\n").map(function (s) { return s.trim(); }).filter(function (s) { return s.length > 0; });
198
+ }
175
199
  function buildTransportRetryTrailer(stepName, repoPath, taskId, attempt, reason) {
176
200
  // reason: "discarded" (the runtime threw the output away — it could not be
177
201
  // machine-read) or "empty" (agent() returned without throwing but produced
@@ -474,12 +498,23 @@ async function parkTask(reason) {
474
498
  }
475
499
  let i = startStepIndex;
476
500
 
477
- // Pin lifecycle scripts
478
- await agent(
479
- "Snapshot lifecycle scripts for version pinning.\n" +
480
- "Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && cp " + ORPHAN_SWEEP_SRC + " " + ORPHAN_SWEEP + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " " + ORPHAN_SWEEP,
481
- { key: "pin-lifecycle", label: "Pinning lifecycle scripts", schema: { type: "object" } }
482
- );
501
+ // ── Pin lifecycle scripts ────────────────────────────────────────────
502
+ // Copy lifecycle scripts into a per-task temp dir so this run is immune
503
+ // to upgrades that land while it's in flight. Verified mechanically:
504
+ // workflow code asserts the four basenames from the verbatim listing
505
+ // the agent cannot self-certify. Any miss parks the task before Triage.
506
+ const initialPins = parsePinListing(await pinLifecycle("pin-lifecycle"));
507
+ const missingInitialPins = PIN_BASENAMES.filter(function (b) { return initialPins.indexOf(b) === -1; });
508
+ if (missingInitialPins.length > 0) {
509
+ return await parkTask("Lifecycle pin incomplete before Triage — missing " + missingInitialPins.join(", ") + " in " + RUN_LIB + ".");
510
+ }
511
+ log("Lifecycle scripts pinned to " + RUN_LIB);
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;
483
518
 
484
519
  while (i < STEPS.length) {
485
520
  const step = STEPS[i];
@@ -525,6 +560,33 @@ while (i < STEPS.length) {
525
560
  }
526
561
  }
527
562
 
563
+ // ── Pre-Publish pin guard ──────────────────────────────────────────
564
+ // Verify the pinned lifecycle scripts still exist on every Publish
565
+ // pass (first pass and rework passes): the pin step trusted agent
566
+ // prose instead of a mechanical check, so task 24be1cd6 walked to
567
+ // Publish on an empty pin dir. One re-pin and re-verify on a miss;
568
+ // still missing parks the task. Keys are attemptKey-scoped so a
569
+ // rework Publish never re-mints a first-pass key.
570
+ if (step.name === "Publish") {
571
+ let publishPins = parsePinListing(await agent(
572
+ "Verify the pinned lifecycle scripts are present.\n" +
573
+ "Run: ls -1 " + RUN_LIB + " 2>/dev/null || echo PIN_DIR_MISSING\n" +
574
+ "Return the verbatim output as { \"listing\": \"<verbatim output>\" } and nothing else.",
575
+ { key: attemptKey("pins-verify-publish", totalReworkCount), label: "Verifying pinned lifecycle scripts",
576
+ schema: { type: "object", properties: { listing: { type: "string" } }, required: ["listing"] } }
577
+ ));
578
+ let missingAtPublish = PIN_BASENAMES.filter(function (b) { return publishPins.indexOf(b) === -1; });
579
+ if (missingAtPublish.length > 0) {
580
+ log("Pin guard at Publish: missing " + missingAtPublish.join(", ") + " — re-pinning once");
581
+ publishPins = parsePinListing(await pinLifecycle(attemptKey("pin-lifecycle-republish", totalReworkCount)));
582
+ missingAtPublish = PIN_BASENAMES.filter(function (b) { return publishPins.indexOf(b) === -1; });
583
+ }
584
+ if (missingAtPublish.length > 0) {
585
+ return await parkTask("Pinned lifecycle scripts missing at Publish even after re-pin: " + missingAtPublish.join(", ") + " in " + RUN_LIB + ".");
586
+ }
587
+ log("Pin guard: all four lifecycle scripts present at Publish");
588
+ }
589
+
528
590
  // Publish is optional and target-based. Empty target = prototyping project: skip the phase.
529
591
  // Unknown target (incl. legacy "repo") = config error: block.
530
592
  if (step.name === "Publish" && !PUBLISH_TYPE) {
@@ -637,6 +699,13 @@ while (i < STEPS.length) {
637
699
  activeSessionId = claimResult.session_id;
638
700
  }
639
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
+
640
709
  // ── Capture: baseline evidence for experiential tasks ─────────────
641
710
  // Hazel's QA capture pass runs right after Triage, before Map, for tasks
642
711
  // Sage flagged experiential. The capture itself is parent-driven (the
@@ -814,11 +883,11 @@ while (i < STEPS.length) {
814
883
 
815
884
  } else if (step.name === "Integrate") {
816
885
  instructions = "Merge the approved task branch into main.\n\n" +
817
- "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" +
818
887
  "Read the output:\n" +
819
888
  "- If it contains MERGED, integration succeeded. Report the merged commit hash.\n" +
820
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" +
821
- "- 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" +
822
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" +
823
892
  "RESOLUTION:\n" +
824
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" +
@@ -906,14 +975,46 @@ while (i < STEPS.length) {
906
975
  }
907
976
  var publishFailure = null;
908
977
  if (rebuildTrigger.edit_started) {
909
- // STEP 1b (mechanical): bounded poll for build completion.
910
- var buildPoll = await agent(
911
- "Poll artifact_status for slug \"" + PUBLISH_SLUG + "\" until no build is running. Check every 30 seconds, up to 20 checks (10 minutes max).\n" +
912
- "Return JSON { \"build_done\": <true if no build is running within budget, false on timeout>, \"status\": \"<final status or timeout note>\" } and nothing else.",
913
- { key: attemptKey("publish-artifact-poll-" + taskId, totalReworkCount), label: "Waiting for artifact build to complete",
914
- schema: { type: "object", properties: { build_done: { type: "boolean" }, status: { type: "string" } }, required: ["build_done"] },
915
- timeoutMs: 660000 }
916
- );
978
+ // STEP 1b (mechanical): bounded poll for build completion, chunked so
979
+ // the merge-lock lease is refreshed before it can expire. The 600s
980
+ // lease is shorter than the worst-case 10-minute build poll, so the
981
+ // poll runs in three chunks (7 checks x 30s ~= 3.5 min each) with a
982
+ // holder-only lease refresh between chunks. If a refresh fails, the
983
+ // lock was lost: stop the run and park the task never continue to
984
+ // a provenance stamp or version assignment without holding the lock.
985
+ var buildPoll = null;
986
+ for (var chunk = 1; chunk <= 3; chunk++) {
987
+ if (chunk > 1) {
988
+ var refreshPoll = await agent(
989
+ "Refresh the merge lock for task " + taskId + ".\n" +
990
+ "Run: " + LIFECYCLE_ENV + " refresh-lock " + taskId + "\n" +
991
+ "If the output contains REFRESHED, return JSON { \"held\": true } and nothing else. Otherwise return JSON { \"held\": false, \"output\": \"<verbatim output>\" } and nothing else.",
992
+ { key: attemptKey("publish-lock-refresh-poll-" + taskId + "-c" + chunk, totalReworkCount), label: "Refreshing merge lock during build poll",
993
+ schema: { type: "object", properties: { held: { type: "boolean" }, output: { type: "string" } }, required: ["held"] },
994
+ timeoutMs: 60000 }
995
+ );
996
+ if (!refreshPoll || refreshPoll.held !== true) {
997
+ 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.");
998
+ }
999
+ }
1000
+ // Chunk 1 keeps the original stable key; later chunks use -c<N>
1001
+ // suffixed keys. All are attemptKey-scoped so rework passes stay
1002
+ // disjoint (replay-key contract).
1003
+ var pollKey = (chunk === 1)
1004
+ ? attemptKey("publish-artifact-poll-" + taskId, totalReworkCount)
1005
+ : attemptKey("publish-artifact-poll-" + taskId + "-c" + chunk, totalReworkCount);
1006
+ buildPoll = await agent(
1007
+ "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" +
1008
+ "Return JSON { \"build_done\": <true if no build is running within budget, false on timeout>, \"status\": \"<final status or timeout note>\" } and nothing else.",
1009
+ { key: pollKey, label: "Waiting for artifact build to complete (chunk " + chunk + " of 3)",
1010
+ schema: { type: "object", properties: { build_done: { type: "boolean" }, status: { type: "string" } }, required: ["build_done"] },
1011
+ timeoutMs: 270000 }
1012
+ );
1013
+ if (buildPoll && buildPoll.build_done) { break; }
1014
+ }
1015
+ if (!buildPoll || !buildPoll.build_done) {
1016
+ buildPoll = { build_done: false, status: (buildPoll && buildPoll.status) || "build still running after the 10.5-minute bounded poll" };
1017
+ }
917
1018
  if (buildPoll.build_done) {
918
1019
  // STEP 1c (mechanical): stamp provenance from workflow-computed values.
919
1020
  var provStamp = await agent(
@@ -34,6 +34,9 @@ const PUBLISH_NPM_SRC = crewHome + "/lib/publish-npm.sh";
34
34
  const PUBLISH_NPM = RUN_LIB + "/publish-npm.sh";
35
35
  const ORPHAN_SWEEP_SRC = crewHome + "/lib/orphan-sweep.sh";
36
36
  const ORPHAN_SWEEP = RUN_LIB + "/orphan-sweep.sh";
37
+ // The four basenames the pin step must materialize — asserted mechanically
38
+ // by workflow code from the verbatim listing, never from agent prose.
39
+ const PIN_BASENAMES = [LIFECYCLE, MERGE_LOCK, PUBLISH_NPM, ORPHAN_SWEEP].map(function (p) { return p.split("/").pop(); });
37
40
 
38
41
  // Project config — passed by dispatcher, falls back to dashboard defaults
39
42
  const projectConfig = inputs.project_config || {};
@@ -170,6 +173,27 @@ function workRetryKey(stepName, reworkSuffix, attempt) {
170
173
  function attemptKey(base, reworkCount) {
171
174
  return base + (reworkCount > 0 ? "-r" + reworkCount : "");
172
175
  }
176
+ // pinLifecycle(key) — snapshot the lifecycle scripts into RUN_LIB and return
177
+ // the verbatim `ls -1` listing so WORKFLOW CODE asserts the four pinned
178
+ // basenames; the agent cannot self-certify. (The pin step was the one place
179
+ // the workflows trusted agent prose: task 24be1cd6 walked to Publish on an
180
+ // empty pin dir.) Byte-identical across standard/bugfix/chore — pinned by
181
+ // tests/pin-location.test.js.
182
+ function pinLifecycle(key) {
183
+ return agent(
184
+ "Snapshot lifecycle scripts for version pinning.\n" +
185
+ "Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && cp " + ORPHAN_SWEEP_SRC + " " + ORPHAN_SWEEP + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " " + ORPHAN_SWEEP + " && ls -1 " + RUN_LIB + "\n" +
186
+ "Return the verbatim output of the ls -1 command as { \"listing\": \"<verbatim output>\" } and nothing else.",
187
+ { key: key, label: "Pinning lifecycle scripts",
188
+ schema: { type: "object", properties: { listing: { type: "string" } }, required: ["listing"] } }
189
+ );
190
+ }
191
+ // parsePinListing(result) — basenames from a pin/verify listing.
192
+ // Byte-identical across standard/bugfix/chore — pinned by
193
+ // tests/pin-location.test.js.
194
+ function parsePinListing(result) {
195
+ return (result && result.listing ? result.listing : "").split("\n").map(function (s) { return s.trim(); }).filter(function (s) { return s.length > 0; });
196
+ }
173
197
  function buildTransportRetryTrailer(stepName, repoPath, taskId, attempt, reason) {
174
198
  // reason: "discarded" (the runtime threw the output away — it could not be
175
199
  // machine-read) or "empty" (agent() returned without throwing but produced
@@ -432,12 +456,23 @@ async function parkTask(reason) {
432
456
  }
433
457
  let i = startStepIndex;
434
458
 
435
- // Pin lifecycle scripts
436
- await agent(
437
- "Snapshot lifecycle scripts for version pinning.\n" +
438
- "Run: mkdir -p " + RUN_LIB + " && cp " + LIFECYCLE_SRC + " " + LIFECYCLE + " && cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + " && cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + " && cp " + ORPHAN_SWEEP_SRC + " " + ORPHAN_SWEEP + " && chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " " + ORPHAN_SWEEP,
439
- { key: "pin-lifecycle", label: "Pinning lifecycle scripts", schema: { type: "object" } }
440
- );
459
+ // ── Pin lifecycle scripts ────────────────────────────────────────────
460
+ // Copy lifecycle scripts into a per-task temp dir so this run is immune
461
+ // to upgrades that land while it's in flight. Verified mechanically:
462
+ // workflow code asserts the four basenames from the verbatim listing
463
+ // the agent cannot self-certify. Any miss parks the task before Triage.
464
+ const initialPins = parsePinListing(await pinLifecycle("pin-lifecycle"));
465
+ const missingInitialPins = PIN_BASENAMES.filter(function (b) { return initialPins.indexOf(b) === -1; });
466
+ if (missingInitialPins.length > 0) {
467
+ return await parkTask("Lifecycle pin incomplete before Triage — missing " + missingInitialPins.join(", ") + " in " + RUN_LIB + ".");
468
+ }
469
+ log("Lifecycle scripts pinned to " + RUN_LIB);
470
+
471
+ // Merge-lock holder identity (bug 2fc8f52f): the opaque task+run identity
472
+ // minted at this run's first claim (never a PID — short-lived agent PIDs
473
+ // made every concurrent acquire take the stale path). Set exactly once on
474
+ // the first claim; stable for the rest of the run's lifetime.
475
+ let lockHolder = taskId;
441
476
 
442
477
  while (i < STEPS.length) {
443
478
  const step = STEPS[i];
@@ -483,6 +518,33 @@ while (i < STEPS.length) {
483
518
  }
484
519
  }
485
520
 
521
+ // ── Pre-Publish pin guard ──────────────────────────────────────────
522
+ // Verify the pinned lifecycle scripts still exist on every Publish
523
+ // pass (first pass and rework passes): the pin step trusted agent
524
+ // prose instead of a mechanical check, so task 24be1cd6 walked to
525
+ // Publish on an empty pin dir. One re-pin and re-verify on a miss;
526
+ // still missing parks the task. Keys are attemptKey-scoped so a
527
+ // rework Publish never re-mints a first-pass key.
528
+ if (step.name === "Publish") {
529
+ let publishPins = parsePinListing(await agent(
530
+ "Verify the pinned lifecycle scripts are present.\n" +
531
+ "Run: ls -1 " + RUN_LIB + " 2>/dev/null || echo PIN_DIR_MISSING\n" +
532
+ "Return the verbatim output as { \"listing\": \"<verbatim output>\" } and nothing else.",
533
+ { key: attemptKey("pins-verify-publish", reworkCount), label: "Verifying pinned lifecycle scripts",
534
+ schema: { type: "object", properties: { listing: { type: "string" } }, required: ["listing"] } }
535
+ ));
536
+ let missingAtPublish = PIN_BASENAMES.filter(function (b) { return publishPins.indexOf(b) === -1; });
537
+ if (missingAtPublish.length > 0) {
538
+ log("Pin guard at Publish: missing " + missingAtPublish.join(", ") + " — re-pinning once");
539
+ publishPins = parsePinListing(await pinLifecycle(attemptKey("pin-lifecycle-republish", reworkCount)));
540
+ missingAtPublish = PIN_BASENAMES.filter(function (b) { return publishPins.indexOf(b) === -1; });
541
+ }
542
+ if (missingAtPublish.length > 0) {
543
+ return await parkTask("Pinned lifecycle scripts missing at Publish even after re-pin: " + missingAtPublish.join(", ") + " in " + RUN_LIB + ".");
544
+ }
545
+ log("Pin guard: all four lifecycle scripts present at Publish");
546
+ }
547
+
486
548
  // Publish is optional and target-based. Empty target = prototyping project: skip the phase.
487
549
  // Unknown target (incl. legacy "repo") = config error: block.
488
550
  if (step.name === "Publish" && !PUBLISH_TYPE) {
@@ -595,6 +657,13 @@ while (i < STEPS.length) {
595
657
  activeSessionId = claimResult.session_id;
596
658
  }
597
659
 
660
+ // Mint the merge-lock holder identity once: task+session of this run's
661
+ // first claim. The task ID alone cannot distinguish two runs of the same
662
+ // task (re-dispatch after park), and a PID is not a valid holder at all.
663
+ if (lockHolder === taskId && activeSessionId) {
664
+ lockHolder = taskId + "/" + activeSessionId;
665
+ }
666
+
598
667
  // ── Capture: baseline evidence for experiential tasks ─────────────
599
668
  // Hazel's QA capture pass runs right after Triage, before Map, for tasks
600
669
  // Sage flagged experiential. The capture itself is parent-driven (the
@@ -751,11 +820,11 @@ while (i < STEPS.length) {
751
820
 
752
821
  } else if (step.name === "Integrate") {
753
822
  instructions = "Merge the approved task branch into main.\n\n" +
754
- "Run: "+ LIFECYCLE_ENV + " integrate " + taskId + " \"merge: chore: " + safeTitle + "\"\n\n" +
823
+ "Run: "+ LIFECYCLE_ENV + "WORKFLOW_RUN_ID=" + lockHolder + " integrate " + taskId + " \"merge: chore: " + safeTitle + "\"\n\n" +
755
824
  "Read the output:\n" +
756
825
  "- If it contains MERGED, integration succeeded. Report the merged commit hash.\n" +
757
826
  "- 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" +
758
- "- 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" +
827
+ "- 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" +
759
828
  "- 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" +
760
829
  "RESOLUTION:\n" +
761
830
  "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" +
@@ -843,14 +912,46 @@ while (i < STEPS.length) {
843
912
  }
844
913
  var publishFailure = null;
845
914
  if (rebuildTrigger.edit_started) {
846
- // STEP 1b (mechanical): bounded poll for build completion.
847
- var buildPoll = await agent(
848
- "Poll artifact_status for slug \"" + PUBLISH_SLUG + "\" until no build is running. Check every 30 seconds, up to 20 checks (10 minutes max).\n" +
849
- "Return JSON { \"build_done\": <true if no build is running within budget, false on timeout>, \"status\": \"<final status or timeout note>\" } and nothing else.",
850
- { key: attemptKey("publish-artifact-poll-" + taskId, reworkCount), label: "Waiting for artifact build to complete",
851
- schema: { type: "object", properties: { build_done: { type: "boolean" }, status: { type: "string" } }, required: ["build_done"] },
852
- timeoutMs: 660000 }
853
- );
915
+ // STEP 1b (mechanical): bounded poll for build completion, chunked so
916
+ // the merge-lock lease is refreshed before it can expire. The 600s
917
+ // lease is shorter than the worst-case 10-minute build poll, so the
918
+ // poll runs in three chunks (7 checks x 30s ~= 3.5 min each) with a
919
+ // holder-only lease refresh between chunks. If a refresh fails, the
920
+ // lock was lost: stop the run and park the task never continue to
921
+ // a provenance stamp or version assignment without holding the lock.
922
+ var buildPoll = null;
923
+ for (var chunk = 1; chunk <= 3; chunk++) {
924
+ if (chunk > 1) {
925
+ var refreshPoll = await agent(
926
+ "Refresh the merge lock for task " + taskId + ".\n" +
927
+ "Run: " + LIFECYCLE_ENV + " refresh-lock " + taskId + "\n" +
928
+ "If the output contains REFRESHED, return JSON { \"held\": true } and nothing else. Otherwise return JSON { \"held\": false, \"output\": \"<verbatim output>\" } and nothing else.",
929
+ { key: attemptKey("publish-lock-refresh-poll-" + taskId + "-c" + chunk, reworkCount), label: "Refreshing merge lock during build poll",
930
+ schema: { type: "object", properties: { held: { type: "boolean" }, output: { type: "string" } }, required: ["held"] },
931
+ timeoutMs: 60000 }
932
+ );
933
+ if (!refreshPoll || refreshPoll.held !== true) {
934
+ 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.");
935
+ }
936
+ }
937
+ // Chunk 1 keeps the original stable key; later chunks use -c<N>
938
+ // suffixed keys. All are attemptKey-scoped so rework passes stay
939
+ // disjoint (replay-key contract).
940
+ var pollKey = (chunk === 1)
941
+ ? attemptKey("publish-artifact-poll-" + taskId, reworkCount)
942
+ : attemptKey("publish-artifact-poll-" + taskId + "-c" + chunk, reworkCount);
943
+ buildPoll = await agent(
944
+ "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" +
945
+ "Return JSON { \"build_done\": <true if no build is running within budget, false on timeout>, \"status\": \"<final status or timeout note>\" } and nothing else.",
946
+ { key: pollKey, label: "Waiting for artifact build to complete (chunk " + chunk + " of 3)",
947
+ schema: { type: "object", properties: { build_done: { type: "boolean" }, status: { type: "string" } }, required: ["build_done"] },
948
+ timeoutMs: 270000 }
949
+ );
950
+ if (buildPoll && buildPoll.build_done) { break; }
951
+ }
952
+ if (!buildPoll || !buildPoll.build_done) {
953
+ buildPoll = { build_done: false, status: (buildPoll && buildPoll.status) || "build still running after the 10.5-minute bounded poll" };
954
+ }
854
955
  if (buildPoll.build_done) {
855
956
  // STEP 1c (mechanical): stamp provenance from workflow-computed values.
856
957
  var provStamp = await agent(