muse-crew 0.14.5 → 0.14.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.
Files changed (41) hide show
  1. package/API.md +106 -12
  2. package/docs/decisions/AGENTS.md +5 -0
  3. package/docs/decisions/publish-path.md +247 -9
  4. package/docs/decisions/qa-reproduce.md +30 -0
  5. package/docs/guide.md +41 -5
  6. package/docs/publish-unknown-recovery.md +28 -120
  7. package/docs/publish-verification.md +148 -501
  8. package/lib/AGENTS.md +9 -13
  9. package/lib/advance-publish-base.js +3 -3
  10. package/lib/compose-evidence-caption.js +1 -2
  11. package/lib/compute-publish-diff.js +82 -5
  12. package/lib/crew-api.js +1134 -1027
  13. package/lib/merge-lock.sh +153 -41
  14. package/lib/publish-note-vocabulary.js +135 -42
  15. package/lib/qa-db.js +132 -0
  16. package/lib/schema.sql +69 -0
  17. package/lib/serve-artifact.js +46 -2
  18. package/lib/test-detached-integrate.sh +122 -0
  19. package/lib/test-merge-lock.sh +30 -1
  20. package/lib/worktree-lifecycle.sh +284 -34
  21. package/package.json +1 -1
  22. package/seed/AGENTS.md +1 -0
  23. package/seed/cron-body-ack-scan.md +44 -0
  24. package/seed/cron-body-template.md +55 -89
  25. package/seed/crons.json +12 -0
  26. package/workflows/AGENTS.md +1 -1
  27. package/workflows/bugfix.js +489 -224
  28. package/workflows/chore.js +306 -226
  29. package/workflows/crew-dispatch.js +57 -4
  30. package/workflows/crew-init.js +23 -0
  31. package/workflows/crew-uninstall.js +7 -4
  32. package/workflows/docs.js +24 -2
  33. package/workflows/standard.js +494 -251
  34. package/workflows/upgrade.js +13 -1
  35. package/lib/build-readback-request.js +0 -140
  36. package/lib/check-intent-freshness.js +0 -101
  37. package/lib/classify-publish-absence.js +0 -462
  38. package/lib/publish-content.js +0 -154
  39. package/lib/readback-disk.js +0 -195
  40. package/lib/retry-publish.js +0 -417
  41. package/lib/verify-publish.js +0 -416
@@ -14,9 +14,17 @@
14
14
  //
15
15
  // Fidelity notes (what this is and isn't):
16
16
  // - The served client and action handlers are the artifact's own built code.
17
- // - The Ctx is locally built: privileged handlers run from the space's own
17
+ // - The Ctx is locally built: ctx.db is a drizzle db over a fresh per-run
18
+ // SQLite database, migrated from the space's own drizzle/ migrations (the
19
+ // same migration path a fresh production install takes) — never the
20
+ // shipped app.db, so the server stays read-only w.r.t. the space
21
+ // directory and QA writes can never contaminate production data;
22
+ // privileged handlers run from the space's own
18
23
  // server/dist/privileged.js when present; blobs are stored in a per-run
19
- // temp dir and served back at /__blobs/<key>.
24
+ // temp dir and served back at /__blobs/<key>. When the space has no
25
+ // drizzle migrations (or drizzle-orm can't be resolved from its
26
+ // node_modules), the server still boots and ctx.db throws a clear error
27
+ // when called.
20
28
  // - Environment (CREW_HOME and friends) is inherited from the caller — export
21
29
  // what the artifact's server needs before starting this.
22
30
  //
@@ -27,6 +35,7 @@ import { join, normalize, dirname, sep } from "node:path";
27
35
  import { existsSync } from "node:fs";
28
36
  import { readFile, writeFile, mkdir, mkdtemp } from "node:fs/promises";
29
37
  import { tmpdir } from "node:os";
38
+ import { openQaDb } from "./qa-db.js";
30
39
 
31
40
  function parseArgs(argv) {
32
41
  const out = { spaceDir: null, port: 0, tag: null };
@@ -85,6 +94,23 @@ async function main() {
85
94
  privilegedByName = new Map(privilegedHandlers.entries.map((e) => [e.contract.name, e.handler]));
86
95
  }
87
96
 
97
+ // ctx.db: best-effort. A space with drizzle/ migrations gets a working db
98
+ // (fresh per-run, migrated from the space's own migrations — the harness
99
+ // can finally drive db-backed success paths); anything else still boots
100
+ // and ctx.db throws a clear error when called, instead of 500ing as
101
+ // "not a function".
102
+ let dbAccessor;
103
+ let qaHandle = null;
104
+ try {
105
+ const qa = await openQaDb(args.spaceDir);
106
+ dbAccessor = () => qa.db;
107
+ qaHandle = qa;
108
+ } catch (err) {
109
+ const reason = String((err && err.message) || err);
110
+ process.stderr.write("serve-artifact.js: ctx.db unavailable: " + reason.slice(0, 200) + "\n");
111
+ dbAccessor = () => { throw new Error("ctx.db is not available in the local QA server: " + reason); };
112
+ }
113
+
88
114
  // Local blob store (outside the space dir — the server stays read-only
89
115
  // w.r.t. the space). Keys are path-safe segments; anything else is rejected.
90
116
  const BLOB_DIR = await mkdtemp(join(tmpdir(), "serve-artifact-blobs-"));
@@ -100,6 +126,7 @@ async function main() {
100
126
  function makeCtx(def) {
101
127
  const declared = new Set(((def && def.privileged) || []).map((c) => c.name));
102
128
  return {
129
+ db: dbAccessor,
103
130
  async executePrivileged(contract, actionArgs) {
104
131
  if (!contract || typeof contract.name !== "string") throw new Error("executePrivileged: bad contract descriptor");
105
132
  if (!declared.has(contract.name)) throw new Error("executePrivileged(" + contract.name + ") was not declared by this action");
@@ -201,6 +228,23 @@ async function main() {
201
228
  server.listen(args.port, "127.0.0.1", () => {
202
229
  process.stdout.write("READY port=" + server.address().port + "\n");
203
230
  });
231
+
232
+ // Graceful shutdown: SIGTERM/SIGINT close the listener and the QA db, then
233
+ // exit. (An earlier revision only closed the db on signal — the process
234
+ // ignored termination and leaked. Caught by tests/serve-artifact-db.test.js.)
235
+ let shuttingDown = false;
236
+ for (const sig of ["SIGTERM", "SIGINT"]) {
237
+ process.on(sig, () => {
238
+ if (shuttingDown) return;
239
+ shuttingDown = true;
240
+ const finish = () => {
241
+ try { if (qaHandle) qaHandle.close(); } catch {}
242
+ process.exit(0);
243
+ };
244
+ server.close(finish);
245
+ setTimeout(finish, 1500).unref();
246
+ });
247
+ }
204
248
  }
205
249
 
206
250
  main().catch((e) => {
@@ -50,6 +50,18 @@
50
50
  # crash): re-running integrate takes the lock and pushes instead of
51
51
  # parking on MERGED_EMPTY.
52
52
  #
53
+ # Fixture F3 (stale-record recovery — blocker 35, 2026-09-21): rework moved
54
+ # the task branch backward after the merge was recorded, so the recorded
55
+ # merge carries abandoned work. Re-running integrate must fail closed
56
+ # with STALE_MERGE — never push the stale state, never fall through to
57
+ # MERGED_EMPTY (an unpushed merge sits on the line).
58
+ #
59
+ # Fixture I (push-target identity — blocker 35, 2026-09-21): the
60
+ # ERROR-after-MERGED retry path. I1: stale record → STALE_MERGE and the
61
+ # remote ref does not move. I2: branch restored to the merged tip → the
62
+ # legitimate retry still PUSHEDs. I3: the manual R5 shape (stale record
63
+ # plus a newer manual merge of the current tip on the line) → PUSHEDs.
64
+ #
53
65
  # Fixture H (detached HEAD with an origin — 2026-09-19 detached-HEAD audit
54
66
  # REDO): H1 proves push-destination fails closed when origin/HEAD is unset
55
67
  # (genuinely unknowable destination); H2-H5 prove the detached line pushes
@@ -327,6 +339,116 @@ echo "$out" | grep -q '^MERGED_EMPTY' && fail "F2: wrongly MERGED_EMPTY while th
327
339
 
328
340
  echo "fixture F (merged-but-unpushed recovery): all checks passed"
329
341
 
342
+ # ---------------- Fixture F3: stale record — branch moved after the merge ----------------
343
+ # (blocker 35, room #26 J3, 2026-09-21): rework moved the task branch
344
+ # BACKWARD after the merge was recorded (the recorded merge now carries
345
+ # abandoned work). The recovery must NOT push the stale recorded state
346
+ # labeled as the deliverable — STALE_MERGE fails closed, and it must not
347
+ # fall through to MERGED_EMPTY either (an unpushed merge sits on the
348
+ # line; MERGED_EMPTY would report PASS while a later publish diff would
349
+ # still carry the abandoned work).
350
+ # Reuse fixture D's repo (no remote): new task, merge, then reset the
351
+ # branch to its pre-work base.
352
+ cd "$D_DIR"
353
+ export CREW_REPO="$D_DIR"
354
+ export CREW_HOME="$T/home-d"
355
+ tid="stale1"
356
+ # Fixture F2's integrate left noremote1's lock held — release it so this
357
+ # task's integrate doesn't back off on a foreign holder.
358
+ "$REPO_DIR/lib/merge-lock.sh" release "noremote1" >/dev/null 2>&1 || fail "F3: lock release failed"
359
+ out=$(bash "$LIFECYCLE" prepare "$tid") || fail "F3 prepare failed: $out"
360
+ BRANCH=$(bash "$LIFECYCLE" resolve-branch "$tid") || fail "F3 resolve-branch failed"
361
+ F3_BASE=$(git rev-parse "$BRANCH")
362
+ echo work >> "$D_DIR/.worktrees/$tid/f.txt"
363
+ (cd "$D_DIR/.worktrees/$tid" && git commit -qam "work w1")
364
+ F3_W1=$(git rev-parse "$BRANCH")
365
+ git worktree remove --force "$D_DIR/.worktrees/$tid" || fail "F3: worktree remove failed"
366
+ out=$(bash "$LIFECYCLE" integrate "$tid" "merge: $tid" 2>&1) || fail "F3 integrate failed: $out"
367
+ echo "$out" | grep -q '^MERGED:' || fail "F3: expected MERGED, got: $out"
368
+ F3_M1=$(git rev-parse HEAD)
369
+ # Rework abandons w1: the branch goes back to its pre-work base.
370
+ git branch -q -f "$BRANCH" "$F3_BASE" || fail "F3: branch reset failed"
371
+ [ "$(git rev-parse "$BRANCH")" = "$F3_BASE" ] || fail "F3: branch not reset"
372
+ "$REPO_DIR/lib/merge-lock.sh" release "$tid" >/dev/null 2>&1 || fail "F3: lock release failed"
373
+ out=$(bash "$LIFECYCLE" integrate "$tid" "merge: $tid" 2>&1) && \
374
+ fail "F3: integrate with a stale record should fail, got: $out"
375
+ echo "$out" | grep -q '^STALE_MERGE' || fail "F3: expected STALE_MERGE, got: $out"
376
+ echo "$out" | grep -q '^MERGED_EMPTY' && fail "F3: fell through to MERGED_EMPTY on a stale record: $out"
377
+ echo "$out" | grep -q 'recovered:' && fail "F3: recovered a stale merge: $out"
378
+ [ "$(git rev-parse HEAD)" = "$F3_M1" ] || fail "F3: HEAD moved during STALE_MERGE"
379
+ echo "fixture F3 (stale record fails closed on re-integrate): all checks passed"
380
+
381
+ # ---------------- Fixture I: branch + origin — stale push-target and the R5-manual shape ----------------
382
+ # (blocker 35): the ERROR-after-MERGED retry path (push-target) pushed live
383
+ # HEAD with no check. I1: stale record → STALE_MERGE, the remote ref does
384
+ # not move. I2: branch restored to the merged tip → the legitimate retry
385
+ # still PUSHEDs (identity check is not a refusal policy). I3: the manual
386
+ # R5 shape (stale record + a newer manual merge of the CURRENT tip on the
387
+ # line) → PUSHEDs — the identity check must not strand a good line.
388
+ I_DIR="$T/fixture-i"
389
+ I_REMOTE="$T/remote-i.git"
390
+ git init -q --bare "$I_REMOTE"
391
+ mkdir -p "$I_DIR"
392
+ cd "$I_DIR"
393
+ git init -q -b main .
394
+ git config user.email test@test.t
395
+ git config user.name test
396
+ echo base > f.txt
397
+ git add -A
398
+ git commit -qm "base"
399
+ git remote add origin "$I_REMOTE"
400
+ git push -q origin main
401
+ git symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main
402
+ export CREW_REPO="$I_DIR"
403
+ export CREW_HOME="$I_DIR/home"
404
+ export CREW_LIB="$REPO_DIR/lib"
405
+ tid="staleorigin1"
406
+ out=$(bash "$LIFECYCLE" prepare "$tid") || fail "I prepare failed: $out"
407
+ BRANCH=$(bash "$LIFECYCLE" resolve-branch "$tid") || fail "I resolve-branch failed"
408
+ I_BASE=$(git rev-parse "$BRANCH")
409
+ echo work >> "$I_DIR/.worktrees/$tid/f.txt"
410
+ (cd "$I_DIR/.worktrees/$tid" && git commit -qam "work w1")
411
+ I_W1=$(git rev-parse "$BRANCH")
412
+ git worktree remove --force "$I_DIR/.worktrees/$tid" || fail "I: worktree remove failed"
413
+ out=$(bash "$LIFECYCLE" integrate "$tid" "merge: $tid" 2>&1) || fail "I integrate failed: $out"
414
+ echo "$out" | grep -q '^MERGED:' || fail "I: expected MERGED, got: $out"
415
+ echo "$out" | grep -q '^PUSHED: origin/main' || fail "I: expected inline PUSHED, got: $out"
416
+ I_M1=$(git rev-parse HEAD)
417
+ [ "$(git --git-dir="$I_REMOTE" rev-parse refs/heads/main)" = "$I_M1" ] || fail "I: remote not at M1"
418
+
419
+ # I1: rework abandons w1 (branch back to base); push-target must refuse.
420
+ git branch -q -f "$BRANCH" "$I_BASE" || fail "I1: branch reset failed"
421
+ out=$(bash "$LIFECYCLE" push-target "$tid" 2>&1) && \
422
+ fail "I1: push-target with a stale record should fail, got: $out"
423
+ echo "$out" | grep -q '^STALE_MERGE' || fail "I1: expected STALE_MERGE, got: $out"
424
+ [ "$(git --git-dir="$I_REMOTE" rev-parse refs/heads/main)" = "$I_M1" ] || \
425
+ fail "I1: remote moved despite STALE_MERGE"
426
+ echo "fixture I1 (stale push-target fails closed): passed"
427
+
428
+ # I2: branch restored to the merged tip — the legitimate retry pushes.
429
+ git branch -q -f "$BRANCH" "$I_W1" || fail "I2: branch restore failed"
430
+ out=$(bash "$LIFECYCLE" push-target "$tid" 2>&1) || fail "I2 push-target failed: $out"
431
+ echo "$out" | grep -q '^PUSH_IDENTITY_OK' || fail "I2: expected PUSH_IDENTITY_OK, got: $out"
432
+ echo "$out" | grep -q '^PUSHED: origin/main' || fail "I2: expected PUSHED, got: $out"
433
+ echo "fixture I2 (legitimate retry still pushes): passed"
434
+
435
+ # I3: the manual R5 shape — stale record M1, but the line carries a newer
436
+ # manual merge of the CURRENT tip w2. push-target must PUSH (no regression).
437
+ echo work2 >> "$I_DIR/.worktrees/$tid/f.txt"
438
+ (cd "$I_DIR/.worktrees/$tid" && git commit -qam "work w2")
439
+ I_W2=$(git rev-parse "$BRANCH")
440
+ git merge -q --no-ff "$BRANCH" -m "manual r5 merge" || fail "I3: manual merge failed"
441
+ I_R=$(git rev-parse HEAD)
442
+ [ "$(git rev-list --parents -n 1 "$I_R" | awk '{print $3}')" = "$I_W2" ] || \
443
+ fail "I3: manual merge ^2 is not the branch tip"
444
+ out=$(bash "$LIFECYCLE" push-target "$tid" 2>&1) || fail "I3 push-target failed: $out"
445
+ echo "$out" | grep -q '^PUSHED: origin/main' || fail "I3: expected PUSHED for the R5-manual shape, got: $out"
446
+ [ "$(git --git-dir="$I_REMOTE" rev-parse refs/heads/main)" = "$I_R" ] || \
447
+ fail "I3: remote not at the manual merge"
448
+ echo "fixture I3 (R5-manual shape still pushes): passed"
449
+
450
+ echo "fixture I (stale push-target identity): all checks passed"
451
+
330
452
  # ---------------- Fixture G: push-target after MERGED_EMPTY ----------------
331
453
  # (2026-09-19 REVIEW #8: MERGED_EMPTY takes no lock and writes no record —
332
454
  # push-target must skip loudly, not report a misleading "lock lost".)
@@ -7,7 +7,10 @@
7
7
  # process is alive (PID liveness is never consulted); an expired lease is
8
8
  # reclaimed with logging; refresh extends the lease; release is
9
9
  # holder-only; status emits parseable holder/age/remaining fields; every
10
- # op appends to the merge-lock audit log. Exits 0 only if every case passes.
10
+ # op appends to the merge-lock audit log. Case 14 pins the R-B1 fix: the
11
+ # stale break's read→rm→create is serialized on the sidecar flock, so 8
12
+ # concurrent reclaimers of an expired lease produce exactly one winner per
13
+ # round (60 rounds). Exits 0 only if every case passes.
11
14
 
12
15
  set -uo pipefail
13
16
 
@@ -120,6 +123,32 @@ out=$(env -u CREW_REPO -u CREW_HOME "$LOCK" acquire task-x "task-x/run" 2>&1); c
120
123
  [ "$code" -eq 2 ] && [[ "$out" == *"BLOCKED"* ]] \
121
124
  && ok "13c: unset both on acquire exits 2 with BLOCKED" || no "13c: exit $code, out: $out"
122
125
 
126
+ # --- Case 14: concurrent reclaim of an expired lease — exactly one winner ---
127
+ # R-B1 (2026-09-21): the stale break used to rm the lock file before the
128
+ # noclobber create, outside any atomic op, so N concurrent reclaimers could
129
+ # all believe they held the lock (Reliability reproduced 54/60 bad rounds
130
+ # with 8 contenders). The break is now serialized on the sidecar flock with
131
+ # an in-critical-section re-read: exactly one contender exits 0 per round,
132
+ # whatever the interleave (winners counted by exit code, not by reading the
133
+ # lock file afterwards).
134
+ bad_rounds=0
135
+ for round in $(seq 1 60); do
136
+ printf 'task_id=old-task\nholder=old-task/run-old\nacquired_at=1577836800\nlease_seconds=600\n' \
137
+ > "$CREW_REPO/.worktrees/.merge-lock"
138
+ codes_file="$TMPBASE/reclaim-codes-$round"
139
+ rm -f "$codes_file"
140
+ for n in $(seq 1 8); do
141
+ ( "$LOCK" acquire "reclaimer-$n" "reclaimer-$n/run" >/dev/null 2>&1; echo "$?" >> "$codes_file" ) &
142
+ done
143
+ wait
144
+ winners=$(grep -c '^0$' "$codes_file" 2>/dev/null || true)
145
+ rm -f "$codes_file"
146
+ [ "$winners" -eq 1 ] || bad_rounds=$((bad_rounds + 1))
147
+ done
148
+ [ "$bad_rounds" -eq 0 ] \
149
+ && ok "14: 60 rounds x 8 concurrent reclaimers — exactly one exit-0 winner per round" \
150
+ || no "14: $bad_rounds/60 rounds had != 1 winner"
151
+
123
152
  echo ""
124
153
  echo "merge-lock: $pass passed, $fail failed"
125
154
  [ "$fail" -eq 0 ]