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.
@@ -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
@@ -475,21 +499,22 @@ let i = startStepIndex;
475
499
 
476
500
  // ── Pin lifecycle scripts ────────────────────────────────────────────
477
501
  // Copy lifecycle scripts into a per-task temp dir so this run is immune
478
- // to upgrades that land while it's in flight.
479
- await agent(
480
- "Snapshot lifecycle scripts for version pinning.\n" +
481
- "Run these shell commands:\n" +
482
- " mkdir -p " + RUN_LIB + "\n" +
483
- " cp " + LIFECYCLE_SRC + " " + LIFECYCLE + "\n" +
484
- " cp " + MERGE_LOCK_SRC + " " + MERGE_LOCK + "\n" +
485
- " cp " + PUBLISH_NPM_SRC + " " + PUBLISH_NPM + "\n" +
486
- " cp " + ORPHAN_SWEEP_SRC + " " + ORPHAN_SWEEP + "\n" +
487
- " chmod +x " + LIFECYCLE + " " + MERGE_LOCK + " " + PUBLISH_NPM + " " + ORPHAN_SWEEP + "\n" +
488
- "Confirm the files exist by listing " + RUN_LIB + ".",
489
- { key: "pin-lifecycle", label: "Pinning lifecycle scripts", schema: { type: "object" } }
490
- );
502
+ // to upgrades that land while it's in flight. Verified mechanically:
503
+ // workflow code asserts the four basenames from the verbatim listing —
504
+ // the agent cannot self-certify. Any miss parks the task before Triage.
505
+ const initialPins = parsePinListing(await pinLifecycle("pin-lifecycle"));
506
+ const missingInitialPins = PIN_BASENAMES.filter(function (b) { return initialPins.indexOf(b) === -1; });
507
+ if (missingInitialPins.length > 0) {
508
+ return await parkTask("Lifecycle pin incomplete before Triage — missing " + missingInitialPins.join(", ") + " in " + RUN_LIB + ".");
509
+ }
491
510
  log("Lifecycle scripts pinned to " + RUN_LIB);
492
511
 
512
+ // Merge-lock holder identity (bug 2fc8f52f): the opaque task+run identity
513
+ // minted at this run's first claim (never a PID — short-lived agent PIDs
514
+ // made every concurrent acquire take the stale path). Set exactly once on
515
+ // the first claim; stable for the rest of the run's lifetime.
516
+ let lockHolder = taskId;
517
+
493
518
  while (i < STEPS.length) {
494
519
  const step = STEPS[i];
495
520
  const isFirstClaim = (i === startStepIndex && totalReworkCount === 0);
@@ -534,6 +559,33 @@ while (i < STEPS.length) {
534
559
  }
535
560
  }
536
561
 
562
+ // ── Pre-Publish pin guard ──────────────────────────────────────────
563
+ // Verify the pinned lifecycle scripts still exist on every Publish
564
+ // pass (first pass and rework passes): the pin step trusted agent
565
+ // prose instead of a mechanical check, so task 24be1cd6 walked to
566
+ // Publish on an empty pin dir. One re-pin and re-verify on a miss;
567
+ // still missing parks the task. Keys are attemptKey-scoped so a
568
+ // rework Publish never re-mints a first-pass key.
569
+ if (step.name === "Publish") {
570
+ let publishPins = parsePinListing(await agent(
571
+ "Verify the pinned lifecycle scripts are present.\n" +
572
+ "Run: ls -1 " + RUN_LIB + " 2>/dev/null || echo PIN_DIR_MISSING\n" +
573
+ "Return the verbatim output as { \"listing\": \"<verbatim output>\" } and nothing else.",
574
+ { key: attemptKey("pins-verify-publish", totalReworkCount), label: "Verifying pinned lifecycle scripts",
575
+ schema: { type: "object", properties: { listing: { type: "string" } }, required: ["listing"] } }
576
+ ));
577
+ let missingAtPublish = PIN_BASENAMES.filter(function (b) { return publishPins.indexOf(b) === -1; });
578
+ if (missingAtPublish.length > 0) {
579
+ log("Pin guard at Publish: missing " + missingAtPublish.join(", ") + " — re-pinning once");
580
+ publishPins = parsePinListing(await pinLifecycle(attemptKey("pin-lifecycle-republish", totalReworkCount)));
581
+ missingAtPublish = PIN_BASENAMES.filter(function (b) { return publishPins.indexOf(b) === -1; });
582
+ }
583
+ if (missingAtPublish.length > 0) {
584
+ return await parkTask("Pinned lifecycle scripts missing at Publish even after re-pin: " + missingAtPublish.join(", ") + " in " + RUN_LIB + ".");
585
+ }
586
+ log("Pin guard: all four lifecycle scripts present at Publish");
587
+ }
588
+
537
589
  // Publish is optional and target-based. Empty target = prototyping project: skip the phase.
538
590
  // Unknown target (incl. legacy "repo") = config error: block.
539
591
  if (step.name === "Publish" && !PUBLISH_TYPE) {
@@ -646,6 +698,13 @@ while (i < STEPS.length) {
646
698
  activeSessionId = claimResult.session_id;
647
699
  }
648
700
 
701
+ // Mint the merge-lock holder identity once: task+session of this run's
702
+ // first claim. The task ID alone cannot distinguish two runs of the same
703
+ // task (re-dispatch after park), and a PID is not a valid holder at all.
704
+ if (lockHolder === taskId && activeSessionId) {
705
+ lockHolder = taskId + "/" + activeSessionId;
706
+ }
707
+
649
708
  // ── Capture: baseline evidence for experiential tasks ─────────────
650
709
  // Hazel's QA capture pass runs right after Triage, before Map, for tasks
651
710
  // Sage flagged experiential. The capture itself is parent-driven (the
@@ -814,11 +873,11 @@ while (i < STEPS.length) {
814
873
 
815
874
  } else if (step.name === "Integrate") {
816
875
  instructions = "Merge the approved task branch into main.\n\n" +
817
- "Run: "+ LIFECYCLE_ENV + " integrate " + taskId + " \"merge: " + safeTitle + "\"\n\n" +
876
+ "Run: "+ LIFECYCLE_ENV + "WORKFLOW_RUN_ID=" + lockHolder + " integrate " + taskId + " \"merge: " + safeTitle + "\"\n\n" +
818
877
  "Read the output:\n" +
819
878
  "- If it contains MERGED, integration succeeded. Report the merged commit hash.\n" +
820
879
  "- 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" +
880
+ "- 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
881
  "- 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
882
  "RESOLUTION:\n" +
824
883
  "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 +965,46 @@ while (i < STEPS.length) {
906
965
  }
907
966
  var publishFailure = null;
908
967
  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
- );
968
+ // STEP 1b (mechanical): bounded poll for build completion, chunked so
969
+ // the merge-lock lease is refreshed before it can expire. The 600s
970
+ // lease is shorter than the worst-case 10-minute build poll, so the
971
+ // poll runs in three chunks (7 checks x 30s ~= 3.5 min each) with a
972
+ // holder-only lease refresh between chunks. If a refresh fails, the
973
+ // lock was lost: stop the run and park the task never continue to
974
+ // a provenance stamp or version assignment without holding the lock.
975
+ var buildPoll = null;
976
+ for (var chunk = 1; chunk <= 3; chunk++) {
977
+ if (chunk > 1) {
978
+ var refreshPoll = await agent(
979
+ "Refresh the merge lock for task " + taskId + ".\n" +
980
+ "Run: " + LIFECYCLE_ENV + " refresh-lock " + taskId + "\n" +
981
+ "If the output contains REFRESHED, return JSON { \"held\": true } and nothing else. Otherwise return JSON { \"held\": false, \"output\": \"<verbatim output>\" } and nothing else.",
982
+ { key: attemptKey("publish-lock-refresh-poll-" + taskId + "-c" + chunk, totalReworkCount), label: "Refreshing merge lock during build poll",
983
+ schema: { type: "object", properties: { held: { type: "boolean" }, output: { type: "string" } }, required: ["held"] },
984
+ timeoutMs: 60000 }
985
+ );
986
+ if (!refreshPoll || refreshPoll.held !== true) {
987
+ 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.");
988
+ }
989
+ }
990
+ // Chunk 1 keeps the original stable key; later chunks use -c<N>
991
+ // suffixed keys. All are attemptKey-scoped so rework passes stay
992
+ // disjoint (replay-key contract).
993
+ var pollKey = (chunk === 1)
994
+ ? attemptKey("publish-artifact-poll-" + taskId, totalReworkCount)
995
+ : attemptKey("publish-artifact-poll-" + taskId + "-c" + chunk, totalReworkCount);
996
+ buildPoll = await agent(
997
+ "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" +
998
+ "Return JSON { \"build_done\": <true if no build is running within budget, false on timeout>, \"status\": \"<final status or timeout note>\" } and nothing else.",
999
+ { key: pollKey, label: "Waiting for artifact build to complete (chunk " + chunk + " of 3)",
1000
+ schema: { type: "object", properties: { build_done: { type: "boolean" }, status: { type: "string" } }, required: ["build_done"] },
1001
+ timeoutMs: 270000 }
1002
+ );
1003
+ if (buildPoll && buildPoll.build_done) { break; }
1004
+ }
1005
+ if (!buildPoll || !buildPoll.build_done) {
1006
+ buildPoll = { build_done: false, status: (buildPoll && buildPoll.status) || "build still running after the 10.5-minute bounded poll" };
1007
+ }
917
1008
  if (buildPoll.build_done) {
918
1009
  // STEP 1c (mechanical): stamp provenance from workflow-computed values.
919
1010
  var provStamp = await agent(