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.
- package/API.md +106 -12
- package/docs/decisions/AGENTS.md +5 -0
- package/docs/decisions/publish-path.md +247 -9
- package/docs/decisions/qa-reproduce.md +30 -0
- package/docs/guide.md +41 -5
- package/docs/publish-unknown-recovery.md +28 -120
- package/docs/publish-verification.md +148 -501
- package/lib/AGENTS.md +9 -13
- package/lib/advance-publish-base.js +3 -3
- package/lib/compose-evidence-caption.js +1 -2
- package/lib/compute-publish-diff.js +82 -5
- package/lib/crew-api.js +1134 -1027
- package/lib/merge-lock.sh +153 -41
- package/lib/publish-note-vocabulary.js +135 -42
- package/lib/qa-db.js +132 -0
- package/lib/schema.sql +69 -0
- package/lib/serve-artifact.js +46 -2
- package/lib/test-detached-integrate.sh +122 -0
- package/lib/test-merge-lock.sh +30 -1
- package/lib/worktree-lifecycle.sh +284 -34
- package/package.json +1 -1
- package/seed/AGENTS.md +1 -0
- package/seed/cron-body-ack-scan.md +44 -0
- package/seed/cron-body-template.md +55 -89
- package/seed/crons.json +12 -0
- package/workflows/AGENTS.md +1 -1
- package/workflows/bugfix.js +489 -224
- package/workflows/chore.js +306 -226
- package/workflows/crew-dispatch.js +57 -4
- package/workflows/crew-init.js +23 -0
- package/workflows/crew-uninstall.js +7 -4
- package/workflows/docs.js +24 -2
- package/workflows/standard.js +494 -251
- package/workflows/upgrade.js +13 -1
- package/lib/build-readback-request.js +0 -140
- package/lib/check-intent-freshness.js +0 -101
- package/lib/classify-publish-absence.js +0 -462
- package/lib/publish-content.js +0 -154
- package/lib/readback-disk.js +0 -195
- package/lib/retry-publish.js +0 -417
- package/lib/verify-publish.js +0 -416
package/lib/serve-artifact.js
CHANGED
|
@@ -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:
|
|
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".)
|
package/lib/test-merge-lock.sh
CHANGED
|
@@ -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.
|
|
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 ]
|