muse-crew 0.14.6 → 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 +45 -5
- package/docs/decisions/AGENTS.md +2 -0
- package/docs/decisions/publish-path.md +56 -8
- package/docs/decisions/qa-reproduce.md +30 -0
- package/lib/AGENTS.md +6 -5
- package/lib/crew-api.js +403 -7
- package/lib/merge-lock.sh +153 -41
- package/lib/qa-db.js +132 -0
- package/lib/schema.sql +50 -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/cron-body-template.md +6 -6
- package/workflows/AGENTS.md +1 -1
- package/workflows/bugfix.js +434 -106
- package/workflows/chore.js +251 -108
- package/workflows/crew-dispatch.js +57 -4
- package/workflows/docs.js +24 -2
- package/workflows/standard.js +439 -111
- package/workflows/upgrade.js +13 -1
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 ]
|
|
@@ -19,6 +19,11 @@
|
|
|
19
19
|
# prepare <task_id> — create worktree + branch, register ownership
|
|
20
20
|
# resolve-branch <task_id> — print the resolved task branch name
|
|
21
21
|
# inspect <task_id> — diff against the integration target for review
|
|
22
|
+
# classify-branch <task_id> — mechanical branch-state classification for Review:
|
|
23
|
+
# has-work | already-merged:<sha> | empty-no-work (pure git, no judgment)
|
|
24
|
+
# version-check <task_id> — mechanical package.json `version` check for Review:
|
|
25
|
+
# VERSION_TOUCHED | VERSION_CLEAN (pure git, no judgment;
|
|
26
|
+
# check failure → stderr + exit 1, fail closed)
|
|
22
27
|
# integrate <task_id> <commit_msg> — merge lock → merge into the integration target → report commit
|
|
23
28
|
# integration-target — print the integration target (branch name, or HEAD when detached)
|
|
24
29
|
# push-target <task_id> — refresh lock, push the integration target (skipped loudly on detached HEAD)
|
|
@@ -62,12 +67,33 @@ MERGE_LOCK="$LIB_DIR/merge-lock.sh"
|
|
|
62
67
|
|
|
63
68
|
# Merge records: $CREW_HOME/.merge-records/<task_id> (key=value, append-only,
|
|
64
69
|
# on persistent disk — same durability class as $CREW_HOME/.pins/<task>).
|
|
65
|
-
#
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
70
|
+
# A durable per-task delivery log: written by cmd_integrate (post-reconcile
|
|
71
|
+
# HEAD), by cmd_integrate's recovery path, and by cmd_push_target's manual
|
|
72
|
+
# path — every merge the lifecycle attributes gets recorded. Read by
|
|
73
|
+
# cmd_verify_merge (branch-reclamation fallback), cmd_push_target's identity
|
|
74
|
+
# check, and classify-branch (Review-entry branch-state classification).
|
|
75
|
+
# Consumers trust a record only after verifying `git merge-base
|
|
76
|
+
# --is-ancestor <merge_commit> <target-tip>` — a stale record is inert, and
|
|
77
|
+
# staleness is decided at read time. Never deleted: the record is the
|
|
78
|
+
# deterministic proof of delivery, not a transient token. Newest block wins
|
|
79
|
+
# (an operator reading the file: the LAST merge_commit= block is
|
|
80
|
+
# authoritative).
|
|
69
81
|
MERGE_RECORDS_DIR="$CREW_HOME/.merge-records"
|
|
70
82
|
|
|
83
|
+
# append_merge_record(task_id, merge_commit, target): append one block to
|
|
84
|
+
# the task's durable merge record. One atomic printf (not three appends —
|
|
85
|
+
# a torn write against a concurrent reader is benign today but not worth
|
|
86
|
+
# the risk), and cut -d= -f2- on read so values containing `=` survive.
|
|
87
|
+
# (merge-lease fix, 2026-09-12; durable per-task delivery log, 2026-09-21.)
|
|
88
|
+
append_merge_record() {
|
|
89
|
+
local task_id="$1" merge_commit="$2" target="$3"
|
|
90
|
+
mkdir -p "$MERGE_RECORDS_DIR"
|
|
91
|
+
printf 'merge_commit=%s\nintegration_target=%s\nmerged_at=%s\n' \
|
|
92
|
+
"$merge_commit" "$target" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
93
|
+
>> "$MERGE_RECORDS_DIR/$task_id"
|
|
94
|
+
echo "RECORDED: merge commit $merge_commit (target $target) in $MERGE_RECORDS_DIR/$task_id"
|
|
95
|
+
}
|
|
96
|
+
|
|
71
97
|
# --- helpers ---
|
|
72
98
|
|
|
73
99
|
# Task branch name. Resolution order (task 4e1a1bba, 2026-09-17):
|
|
@@ -336,6 +362,31 @@ acquire_merge_lock() {
|
|
|
336
362
|
done
|
|
337
363
|
}
|
|
338
364
|
|
|
365
|
+
# current_branch_merge(branch): print the newest merge commit on live HEAD's
|
|
366
|
+
# first-parent chain whose branch-side parent (^2) is the branch's CURRENT
|
|
367
|
+
# tip — i.e. the merge that brought the branch's present state onto the
|
|
368
|
+
# line. Prints nothing and returns 1 when no such merge exists.
|
|
369
|
+
#
|
|
370
|
+
# (blocker 35, room #26 J3, 2026-09-21): the merged-but-unpushed recovery
|
|
371
|
+
# used to check only that the RECORDED merge is an ancestor of live HEAD.
|
|
372
|
+
# Ancestry proves containment, not identity — rework can move the task
|
|
373
|
+
# branch after the merge was recorded, and the recovery would then push
|
|
374
|
+
# the stale recorded state labeled as the task's deliverable (the task
|
|
375
|
+
# later collided with its own stale merge in theme.css). cmd_integrate
|
|
376
|
+
# always merges with --no-ff, so M^2 is the branch tip at merge time;
|
|
377
|
+
# requiring M^2 == current branch tip proves the merge still represents
|
|
378
|
+
# the branch. The first-parent walk also covers the manual R5 merge
|
|
379
|
+
# (scratch-worktree merge, ff'd onto the line) and do_push's reconcile
|
|
380
|
+
# merges above the recorded commit.
|
|
381
|
+
current_branch_merge() {
|
|
382
|
+
local branch="$1" tip h p1 p2 rest
|
|
383
|
+
tip=$(git rev-parse --verify "$branch" 2>/dev/null) || return 1
|
|
384
|
+
while read -r h p1 p2 rest; do
|
|
385
|
+
if [ "$p2" = "$tip" ]; then printf '%s' "$h"; return 0; fi
|
|
386
|
+
done < <(git log --first-parent --min-parents=2 --format='%H %P' HEAD 2>/dev/null)
|
|
387
|
+
return 1
|
|
388
|
+
}
|
|
389
|
+
|
|
339
390
|
# Deterministic push of the integration target. Called inline by
|
|
340
391
|
# cmd_integrate on the main path (the lock was just acquired there — no
|
|
341
392
|
# refresh needed) and by cmd_push_target for the R5 manual-conflict path
|
|
@@ -450,34 +501,52 @@ cmd_integrate() {
|
|
|
450
501
|
# merge landed but its push never completed. Take the lock and push now
|
|
451
502
|
# instead of reporting MERGED_EMPTY — otherwise the retry loop parks
|
|
452
503
|
# the task with the work stranded and no path ever pushes it.
|
|
453
|
-
# The check is
|
|
454
|
-
#
|
|
455
|
-
#
|
|
456
|
-
#
|
|
457
|
-
# push
|
|
504
|
+
# The check is identity-based, not ancestry-based (blocker 35,
|
|
505
|
+
# 2026-09-21): the old ancestor check proved the recorded merge was
|
|
506
|
+
# contained in live HEAD but not that it still represents the task
|
|
507
|
+
# branch — rework moving the branch after the merge made the recovery
|
|
508
|
+
# push stale state. current_branch_merge finds the merge whose ^2 is
|
|
509
|
+
# the branch's CURRENT tip: the recorded merge, a do_push reconcile
|
|
510
|
+
# merge above it, or the manual R5 merge. No such merge plus a record
|
|
511
|
+
# means the branch moved since the merge — STALE_MERGE fails closed
|
|
512
|
+
# (never MERGED_EMPTY: an unpushed merge may sit on the line, and
|
|
513
|
+
# MERGED_EMPTY would report PASS while Publish diffs would still
|
|
514
|
+
# carry the abandoned work).
|
|
458
515
|
local record_file="$MERGE_RECORDS_DIR/$task_id"
|
|
459
516
|
local recorded_commit=""
|
|
460
517
|
if [ -f "$record_file" ]; then
|
|
461
518
|
recorded_commit=$(grep "^merge_commit=" "$record_file" | tail -1 | cut -d= -f2-)
|
|
462
519
|
fi
|
|
463
|
-
local
|
|
464
|
-
|
|
465
|
-
if [ -n "$
|
|
520
|
+
local live_merge
|
|
521
|
+
live_merge=$(current_branch_merge "$branch") || live_merge=""
|
|
522
|
+
if [ -n "$live_merge" ]; then
|
|
466
523
|
acquire_merge_lock "$task_id" || exit 2
|
|
467
524
|
if do_push "$task_id"; then
|
|
468
|
-
|
|
525
|
+
# The recovery path lands merges too — record them, or a later
|
|
526
|
+
# classify-branch sees an unrecorded merge (blocker 34, 2026-09-21).
|
|
527
|
+
append_merge_record "$task_id" "$live_merge" "$target"
|
|
528
|
+
if [ "$live_merge" = "$recorded_commit" ]; then
|
|
529
|
+
echo "MERGED: $live_merge (recovered: merge landed in a prior attempt; push completed now)"
|
|
530
|
+
else
|
|
531
|
+
echo "MERGED: $live_merge (recovered: branch tip merged in $live_merge; recorded ${recorded_commit:-none} superseded — push completed now)"
|
|
532
|
+
fi
|
|
469
533
|
else
|
|
470
|
-
echo "ERROR: push recovery failed for previously-merged $
|
|
534
|
+
echo "ERROR: push recovery failed for previously-merged $live_merge"
|
|
471
535
|
return 1
|
|
472
536
|
fi
|
|
473
537
|
echo "LOCK: held by $task_id — release after deploy with post-deploy"
|
|
474
538
|
return 0
|
|
475
539
|
fi
|
|
540
|
+
if [ -n "$recorded_commit" ]; then
|
|
541
|
+
echo "STALE_MERGE: recorded merge $recorded_commit no longer corresponds to $branch (tip $(git rev-parse --verify "$branch" 2>/dev/null)) — no merge of the current tip is on the live line; refusing to push possibly-stale state"
|
|
542
|
+
return 1
|
|
543
|
+
fi
|
|
476
544
|
# Approved empty diff: the deliverable was runtime state (cron,
|
|
477
|
-
# scheduler, dashboard config) or
|
|
478
|
-
# verified
|
|
479
|
-
# the merge lock is
|
|
480
|
-
|
|
545
|
+
# scheduler, dashboard config) or a task-attributed merge the
|
|
546
|
+
# classifier verified from the task's own merge records — not a repo
|
|
547
|
+
# change. Nothing to merge, nothing to serialize — the merge lock is
|
|
548
|
+
# intentionally not taken.
|
|
549
|
+
echo "MERGED_EMPTY: $branch has no commits ahead of $target — runtime-state deliverable or task-attributed merge, nothing to merge"
|
|
481
550
|
return 0
|
|
482
551
|
fi
|
|
483
552
|
|
|
@@ -524,17 +593,11 @@ cmd_integrate() {
|
|
|
524
593
|
merged_commit=$(git rev-parse HEAD)
|
|
525
594
|
echo "MERGED: $merged_commit"
|
|
526
595
|
|
|
527
|
-
# Record the merge commit
|
|
528
|
-
#
|
|
529
|
-
#
|
|
530
|
-
#
|
|
531
|
-
|
|
532
|
-
# -d= -f2- on read so values containing `=` survive.
|
|
533
|
-
mkdir -p "$MERGE_RECORDS_DIR"
|
|
534
|
-
printf 'merge_commit=%s\nintegration_target=%s\nmerged_at=%s\n' \
|
|
535
|
-
"$merged_commit" "$target" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
536
|
-
>> "$MERGE_RECORDS_DIR/$task_id"
|
|
537
|
-
echo "RECORDED: merge commit $merged_commit (target $target) in $MERGE_RECORDS_DIR/$task_id"
|
|
596
|
+
# Record the merge commit: if the branch is later reclaimed (e.g. under an
|
|
597
|
+
# expired lease), verify-merge can still confirm the merge landed from this
|
|
598
|
+
# record. Durable per-task delivery log — never deleted (blocker 34,
|
|
599
|
+
# 2026-09-21).
|
|
600
|
+
append_merge_record "$task_id" "$merged_commit" "$target"
|
|
538
601
|
|
|
539
602
|
# Push inline while the lock is held — no agent round-trip (the old STEP 2
|
|
540
603
|
# agent run added only a lock refresh). R5's manual-conflict path uses the
|
|
@@ -670,8 +733,11 @@ cmd_post_deploy() {
|
|
|
670
733
|
# Release merge lock
|
|
671
734
|
"$MERGE_LOCK" release "$task_id" 2>/dev/null && echo "LOCK: released" || true
|
|
672
735
|
|
|
673
|
-
#
|
|
674
|
-
|
|
736
|
+
# The merge record is NOT deleted here (blocker 34, 2026-09-21):
|
|
737
|
+
# records are an append-only delivery log — they survive deploy so a
|
|
738
|
+
# later dispatch can still classify the task's branch against the live
|
|
739
|
+
# target. Consumers validate ancestry at read time; a stale record is
|
|
740
|
+
# inert. See the MERGE_RECORDS_DIR contract at the top of this file.
|
|
675
741
|
|
|
676
742
|
echo "DEPLOYED: $final_commit"
|
|
677
743
|
}
|
|
@@ -862,6 +928,114 @@ cmd_integration_target() {
|
|
|
862
928
|
integration_target
|
|
863
929
|
}
|
|
864
930
|
|
|
931
|
+
# Branch-state classification for Review entry (blocker 34, 2026-09-21).
|
|
932
|
+
# Usage: classify-branch <task_id>
|
|
933
|
+
# Stdout: exactly one successful line —
|
|
934
|
+
# BRANCH_STATE: has-work | already-merged:<40-hex> | empty-no-work
|
|
935
|
+
# Stderr: exactly one DIAG line per invocation —
|
|
936
|
+
# DIAG: classify-branch records=<n> resolvable=<m>
|
|
937
|
+
# (blocker 34 design v2.1). Merge records are load-bearing with no
|
|
938
|
+
# fallback: silent record loss must surface as records>0 resolvable=0,
|
|
939
|
+
# never as a lying empty-no-work. The workflow logs the DIAG line
|
|
940
|
+
# verbatim.
|
|
941
|
+
# Resolution failure: exit 1 with diagnostics on stderr, no BRANCH_STATE
|
|
942
|
+
# line (the workflow fails closed; there is no fourth stdout state).
|
|
943
|
+
#
|
|
944
|
+
# Attribution is identity-first, never declared:
|
|
945
|
+
# 1. current-tip identity — the live merge carrying the current branch
|
|
946
|
+
# tip (current_branch_merge, blocker 35's identity primitive). If the
|
|
947
|
+
# tip is on the live line via a merge, that merge IS the deliverable.
|
|
948
|
+
# 2. the task's own durable merge records, newest first (at most 20
|
|
949
|
+
# candidates scanned; garbage/unresolvable entries skipped). The
|
|
950
|
+
# first record that resolves to a MERGE commit and is an ancestor of
|
|
951
|
+
# the live target wins; the SHA is canonicalized to full 40-hex.
|
|
952
|
+
# Poison rule: the newest RESOLVABLE non-ancestor record poisons the
|
|
953
|
+
# fallback — an older record is never accepted beneath a newer known-
|
|
954
|
+
# stale one. The answer then is empty-no-work (stderr notes the poison),
|
|
955
|
+
# never a fabricated already-merged.
|
|
956
|
+
# The branch is empty here, so the blocker-35 identity/ancestry
|
|
957
|
+
# distinction does not apply: there is no moved branch to go stale
|
|
958
|
+
# against, and every candidate is this task's own record.
|
|
959
|
+
cmd_classify_branch() {
|
|
960
|
+
local task_id="$1"
|
|
961
|
+
validate_task_id "$task_id"
|
|
962
|
+
|
|
963
|
+
# DIAG pin (blocker 34 design v2.1): every invocation emits exactly one
|
|
964
|
+
# stderr line counting the durable merge-record scan. Merge records are
|
|
965
|
+
# load-bearing with no fallback — silent record loss (deleted file, torn
|
|
966
|
+
# lines, gc'd objects) must surface as records>0 resolvable=0, never as
|
|
967
|
+
# a lying empty-no-work. records = candidates scanned (valid-hex
|
|
968
|
+
# merge_commit= lines, at most 20); resolvable = candidates that resolved
|
|
969
|
+
# to MERGE commits. Paths that bypass the scan report 0/0. The workflow
|
|
970
|
+
# logs this line verbatim.
|
|
971
|
+
local diag_records=0 diag_resolvable=0
|
|
972
|
+
diag() { echo "DIAG: classify-branch records=$diag_records resolvable=$diag_resolvable" >&2; }
|
|
973
|
+
|
|
974
|
+
local branch
|
|
975
|
+
branch=$(resolve_branch "$task_id") || { echo "classify-branch: cannot resolve task branch" >&2; diag; return 1; }
|
|
976
|
+
|
|
977
|
+
cd "$REPO"
|
|
978
|
+
git rev-parse --verify "$branch" >/dev/null 2>&1 || { echo "classify-branch: branch $branch does not exist" >&2; diag; return 1; }
|
|
979
|
+
|
|
980
|
+
local target
|
|
981
|
+
target=$(integration_target) || { echo "classify-branch: cannot resolve integration target" >&2; diag; return 1; }
|
|
982
|
+
|
|
983
|
+
local ahead
|
|
984
|
+
ahead=$(git rev-list --count "$target..$branch" 2>/dev/null) || { echo "classify-branch: rev-list failed" >&2; diag; return 1; }
|
|
985
|
+
if [ "$ahead" -gt 0 ]; then
|
|
986
|
+
diag
|
|
987
|
+
echo "BRANCH_STATE: has-work"
|
|
988
|
+
return 0
|
|
989
|
+
fi
|
|
990
|
+
|
|
991
|
+
# 1. Current-tip identity.
|
|
992
|
+
local tip_merge
|
|
993
|
+
if tip_merge=$(current_branch_merge "$branch" 2>/dev/null); then
|
|
994
|
+
tip_merge=$(git rev-parse --verify "$tip_merge" 2>/dev/null) || tip_merge=""
|
|
995
|
+
if [ -n "$tip_merge" ] && git merge-base --is-ancestor "$tip_merge" "$target" 2>/dev/null; then
|
|
996
|
+
diag
|
|
997
|
+
echo "BRANCH_STATE: already-merged:$tip_merge"
|
|
998
|
+
return 0
|
|
999
|
+
fi
|
|
1000
|
+
fi
|
|
1001
|
+
|
|
1002
|
+
# 2. Durable merge records, newest first. Empty-file-safe: no pipelines
|
|
1003
|
+
# under set -e/pipefail, at most 20 candidate lines read.
|
|
1004
|
+
local rec="$MERGE_RECORDS_DIR/$task_id"
|
|
1005
|
+
if [ -f "$rec" ]; then
|
|
1006
|
+
local count=0 sha full poisoned=""
|
|
1007
|
+
while IFS= read -r line && [ "$count" -lt 20 ]; do
|
|
1008
|
+
case "$line" in
|
|
1009
|
+
merge_commit=*) sha="${line#merge_commit=}" ;;
|
|
1010
|
+
*) continue ;;
|
|
1011
|
+
esac
|
|
1012
|
+
case "$sha" in ""|*[!0-9a-f]*) continue ;; esac
|
|
1013
|
+
count=$((count + 1))
|
|
1014
|
+
diag_records=$((diag_records + 1))
|
|
1015
|
+
full=$(git rev-parse --verify --quiet "$sha" 2>/dev/null) || continue
|
|
1016
|
+
# A merge commit: the frozen first-parent diff the workflow reviews
|
|
1017
|
+
# only exists for merges. A non-merge ancestor is never the task's
|
|
1018
|
+
# merge — skipped, not poison (garbage must not veto delivery).
|
|
1019
|
+
git rev-parse --verify --quiet "$full^2" >/dev/null 2>&1 || continue
|
|
1020
|
+
diag_resolvable=$((diag_resolvable + 1))
|
|
1021
|
+
if git merge-base --is-ancestor "$full" "$target" 2>/dev/null; then
|
|
1022
|
+
if [ -n "$poisoned" ]; then
|
|
1023
|
+
echo "classify-branch: newest resolvable record $poisoned is stale (not an ancestor of $target); refusing older record $full" >&2
|
|
1024
|
+
break
|
|
1025
|
+
fi
|
|
1026
|
+
diag
|
|
1027
|
+
echo "BRANCH_STATE: already-merged:$full"
|
|
1028
|
+
return 0
|
|
1029
|
+
fi
|
|
1030
|
+
[ -z "$poisoned" ] && poisoned="$full"
|
|
1031
|
+
done < <(tac "$rec" 2>/dev/null)
|
|
1032
|
+
fi
|
|
1033
|
+
|
|
1034
|
+
diag
|
|
1035
|
+
echo "BRANCH_STATE: empty-no-work"
|
|
1036
|
+
return 0
|
|
1037
|
+
}
|
|
1038
|
+
|
|
865
1039
|
# Agent-side one-liner: prints the remote branch a push updates. Attached:
|
|
866
1040
|
# the branch name. Detached: the resolved destination from
|
|
867
1041
|
# resolve_push_destination() (fails when genuinely unknowable).
|
|
@@ -897,8 +1071,22 @@ cmd_push_target() {
|
|
|
897
1071
|
# The lock tells them apart.
|
|
898
1072
|
if cmd_refresh_lock "$task_id" >/dev/null 2>&1; then
|
|
899
1073
|
echo "LOCK: refreshed for push (manual-merge path)"
|
|
900
|
-
|
|
901
|
-
|
|
1074
|
+
# Blocker 34 (2026-09-21): the manual merge has no record yet —
|
|
1075
|
+
# attribute it by current-tip identity, push, and record ONLY after
|
|
1076
|
+
# the push succeeds. A failed push records nothing.
|
|
1077
|
+
local branch manual_merge push_target_name
|
|
1078
|
+
if branch=$(resolve_branch "$task_id" 2>/dev/null) \
|
|
1079
|
+
&& manual_merge=$(current_branch_merge "$branch" 2>/dev/null) \
|
|
1080
|
+
&& [ -n "$manual_merge" ]; then
|
|
1081
|
+
if do_push "$task_id"; then
|
|
1082
|
+
push_target_name=$(integration_target) || push_target_name="unknown"
|
|
1083
|
+
append_merge_record "$task_id" "$manual_merge" "$push_target_name"
|
|
1084
|
+
return 0
|
|
1085
|
+
fi
|
|
1086
|
+
return 1
|
|
1087
|
+
fi
|
|
1088
|
+
echo "ERROR: cannot attribute a manual merge for $task_id — no merge of the current branch tip is on the live line" >&2
|
|
1089
|
+
return 1
|
|
902
1090
|
fi
|
|
903
1091
|
echo "PUSH_SKIPPED: no merge record for $task_id and no lock held -- nothing was merged, nothing to push"
|
|
904
1092
|
return 0
|
|
@@ -912,10 +1100,70 @@ cmd_push_target() {
|
|
|
912
1100
|
fi
|
|
913
1101
|
echo "LOCK: refreshed for push"
|
|
914
1102
|
|
|
1103
|
+
# Blocker-35 (2026-09-21): the ERROR-after-MERGED retry pushed live HEAD
|
|
1104
|
+
# with no check that it still carries the task's deliverable — rework
|
|
1105
|
+
# between the merge and the retry made it push stale state. Prove the
|
|
1106
|
+
# branch's current tip was merged onto the live line before pushing.
|
|
1107
|
+
# Branch gone (merge-lease reclaim): no rework could have moved it, so
|
|
1108
|
+
# the recorded merge on the line is still the deliverable.
|
|
1109
|
+
local branch live_merge recorded_commit
|
|
1110
|
+
recorded_commit=$(grep "^merge_commit=" "$record_file" | tail -1 | cut -d= -f2-)
|
|
1111
|
+
live_merge=""
|
|
1112
|
+
if branch=$(resolve_branch "$task_id" 2>/dev/null); then
|
|
1113
|
+
if git rev-parse --verify "$branch" >/dev/null 2>&1; then
|
|
1114
|
+
live_merge=$(current_branch_merge "$branch") || live_merge=""
|
|
1115
|
+
elif [ -n "$recorded_commit" ] && git merge-base --is-ancestor "$recorded_commit" "$(git rev-parse HEAD 2>/dev/null)" 2>/dev/null; then
|
|
1116
|
+
live_merge="$recorded_commit"
|
|
1117
|
+
fi
|
|
1118
|
+
else
|
|
1119
|
+
echo "ERROR: cannot resolve task branch for $task_id — not pushing without a verifiable branch"
|
|
1120
|
+
return 1
|
|
1121
|
+
fi
|
|
1122
|
+
if [ -z "$live_merge" ]; then
|
|
1123
|
+
echo "STALE_MERGE: no merge of the current ${branch:-unknown} tip is on the live line (recorded: ${recorded_commit:-none}) — the task branch changed since the recorded merge; refusing to push possibly-stale state"
|
|
1124
|
+
return 1
|
|
1125
|
+
fi
|
|
1126
|
+
echo "PUSH_IDENTITY_OK: pushing merge $live_merge carrying the current $branch tip"
|
|
1127
|
+
|
|
915
1128
|
do_push "$task_id"
|
|
916
1129
|
return $?
|
|
917
1130
|
}
|
|
918
1131
|
|
|
1132
|
+
# Usage: version-check <task_id>
|
|
1133
|
+
# Output: exactly one machine line on stdout:
|
|
1134
|
+
# VERSION_TOUCHED — the task branch's three-dot diff against the
|
|
1135
|
+
# integration target touches package.json's `version`
|
|
1136
|
+
# field (versions are assigned at publish time,
|
|
1137
|
+
# never in branches)
|
|
1138
|
+
# VERSION_CLEAN — no `version` change on the branch
|
|
1139
|
+
# A check failure (unresolvable branch/target, diff error) prints
|
|
1140
|
+
# diagnostics on stderr and exits 1 — the workflow treats that as touched
|
|
1141
|
+
# (fail closed). There is no VERSION_CHECK_FAILED stdout token (blocker
|
|
1142
|
+
# 34, 2026-09-21): stdout carries only the two real states.
|
|
1143
|
+
# A deterministic git fact for Review: the reviewer never adjudicates it.
|
|
1144
|
+
cmd_version_check() {
|
|
1145
|
+
local task_id="$1"
|
|
1146
|
+
validate_task_id "$task_id"
|
|
1147
|
+
|
|
1148
|
+
local branch
|
|
1149
|
+
branch=$(resolve_branch "$task_id") || { echo "version-check: cannot resolve task branch" >&2; return 1; }
|
|
1150
|
+
|
|
1151
|
+
cd "$REPO"
|
|
1152
|
+
git rev-parse --verify "$branch" >/dev/null 2>&1 || { echo "version-check: branch $branch does not exist" >&2; return 1; }
|
|
1153
|
+
|
|
1154
|
+
local target
|
|
1155
|
+
target=$(integration_target) || { echo "version-check: cannot resolve integration target" >&2; return 1; }
|
|
1156
|
+
|
|
1157
|
+
local diff
|
|
1158
|
+
diff=$(git diff "$target...$branch" -- package.json 2>/dev/null) || { echo "version-check: diff failed" >&2; return 1; }
|
|
1159
|
+
if printf '%s' "$diff" | grep -qE '^[+-].*"version"[[:space:]]*:'; then
|
|
1160
|
+
echo "VERSION_TOUCHED"
|
|
1161
|
+
else
|
|
1162
|
+
echo "VERSION_CLEAN"
|
|
1163
|
+
fi
|
|
1164
|
+
return 0
|
|
1165
|
+
}
|
|
1166
|
+
|
|
919
1167
|
# --- dispatch ---
|
|
920
1168
|
|
|
921
1169
|
cmd="${1:-}"
|
|
@@ -929,6 +1177,8 @@ case "$cmd" in
|
|
|
929
1177
|
integrate) cmd_integrate "${1:?task_id required}" "${2:-}" ;;
|
|
930
1178
|
verify-merge) cmd_verify_merge "${1:?task_id required}" ;;
|
|
931
1179
|
integration-target) cmd_integration_target ;;
|
|
1180
|
+
classify-branch) cmd_classify_branch "${1:?task_id required}" ;;
|
|
1181
|
+
version-check) cmd_version_check "${1:?task_id required}" ;;
|
|
932
1182
|
push-destination) cmd_push_destination ;;
|
|
933
1183
|
push-target) cmd_push_target "${1:?task_id required}" ;;
|
|
934
1184
|
post-deploy) cmd_post_deploy "${1:?task_id required}" ;;
|
|
@@ -940,7 +1190,7 @@ case "$cmd" in
|
|
|
940
1190
|
merge-record) cmd_merge_record "${1:?task_id required}" ;;
|
|
941
1191
|
*)
|
|
942
1192
|
echo "Usage: worktree-lifecycle.sh <command> [args]"
|
|
943
|
-
echo "Commands: validate, prepare, resolve-branch, inspect, integrate, integration-target, push-target, verify-merge, post-deploy, terminal-cleanup, cleanup, status, refresh-lock, lock-status, merge-record"
|
|
1193
|
+
echo "Commands: validate, prepare, resolve-branch, inspect, classify-branch, version-check, integrate, integration-target, push-target, verify-merge, post-deploy, terminal-cleanup, cleanup, status, refresh-lock, lock-status, merge-record"
|
|
944
1194
|
exit 1
|
|
945
1195
|
;;
|
|
946
1196
|
esac
|
package/package.json
CHANGED
|
@@ -41,9 +41,9 @@ The failures below are settled and recorded in the Gate 1 OODA state. The author
|
|
|
41
41
|
|
|
42
42
|
1. **Load tools:** Call tool_search_load_tool_namespace with paths ["workflow_launch"].
|
|
43
43
|
|
|
44
|
-
2. **Load the workflow registry:** Read the file "{crewHome}/workflows/registry.json" with the read tool and parse it as JSON. If the file does not exist (the live release predates the registry), proceed without it — omit the `
|
|
44
|
+
2. **Load the workflow registry:** Read the file "{crewHome}/workflows/registry.json" with the read tool and keep its RAW TEXT — do NOT parse it, do NOT re-emit it as a JSON object. Pass the text verbatim: never reformat, summarize, or hand-transcribe it (room #26: 15+ launch rejections 09-19→09-21 from garbled nested-object args, and one hallucinated registry that passed validation). If the file does not exist (the live release predates the registry), proceed without it — omit the `registryText` arg and the dispatcher will load the registry the slow way and log a warning. If the read FAILS on a file that exists (transient read error — observed 2026-09-13; the file itself was healthy and later ticks read it fine), retry the read once; if it still fails, write the error text into your run summary (observability — never silently swallow a failed read) and proceed without the registry the same way.
|
|
45
45
|
|
|
46
|
-
3. **Run the dispatcher:** Call workflow_launch with scriptPath "{crewHome}/workflows/crew-dispatch.js" and args {"crewHome": "{crewHome}", "
|
|
46
|
+
3. **Run the dispatcher:** Call workflow_launch with scriptPath "{crewHome}/workflows/crew-dispatch.js" and args {"crewHome": "{crewHome}", "registryText": <raw registry file text, or omit the key when the file was missing>}. The dispatcher parses the text itself — a nested-object `registry` arg is not accepted. If the launch is rejected, retry once with only {"crewHome": "{crewHome}"} (the sanctioned fallback — the dispatcher loads the registry the slow way); a second rejection is a platform problem, not an args problem — write it in your run summary and move on.
|
|
47
47
|
|
|
48
48
|
Wait for it to complete. It reads the crew's task state, determines eligibility, claims tasks, acknowledges the poll, and returns structured results.
|
|
49
49
|
|
|
@@ -69,7 +69,7 @@ The failures below are settled and recorded in the Gate 1 OODA state. The author
|
|
|
69
69
|
then re-run the sha256sum. If the regeneration exits non-zero, or the hash still does not match after regeneration, the diff is UNISSUABLE — record it terminally (code, never prose):
|
|
70
70
|
`node {crewHome}/lib/crew-api.js --crew-home {crewHome} record-intent-unissuable --json '{"task_id": "<task_id>", "claim_expiry": "<claim_expiry>", "reason": "<missing-staged-diff|diff-unregenerable|hash-mismatch-after-regeneration>"}'`
|
|
71
71
|
(copy `claim_expiry` verbatim from the scan entry — it is the compare-and-swap). This writes the terminal `publish: publish-unissuable` note and an issuer-stamped ledger entry — parked for human attention. Never re-claim, never loop on an hourly reclaim; a diff that cannot be checksummed never becomes an edit.
|
|
72
|
-
2. **Re-claim check — the version receipt decides (code):** when the entry's `reclaimed` is true, a previous tick died mid-issuance and may have issued the edit without recording it. Read the artifact's on-disk version receipt `~/workspace/ts-spaces/<slug>/.crew-publish-version-<task_id>` (trim whitespace) and compare it to the entry's `version`. If it EQUALS the dead attempt's version → the edit landed: call `record-version-ack --json '{"task_id":"<task_id>","version":"<entry version>","via":"disk"}'` and NEVER re-issue. If it differs
|
|
72
|
+
2. **Re-claim check — the version receipt decides (code):** when the entry's `reclaimed` is true, a previous tick died mid-issuance and may have issued the edit without recording it. Read the artifact's on-disk version receipt `~/workspace/ts-spaces/<slug>/.crew-publish-version-<task_id>` (trim whitespace) and compare it to the entry's `version`. If it EQUALS the dead attempt's version → the edit landed: call `record-version-ack --json '{"task_id":"<task_id>","version":"<entry version>","via":"disk"}'` and NEVER re-issue (report `receipt_check: "match"` for the record). If it differs → `receipt_check: "different"`. If the file is missing or unreadable → `receipt_check: "unreadable"`. A differing or unreadable receipt is NOT proof the edit did not land — the async edit may have issued while the receipt was never applied or is not yet visible. Never decide this from prose, from `artifact_status`, or from an inspector's summary. You MUST run this check and report its exact outcome to the record step (step 4) — the record API refuses reclaimed claims without an explicit `receipt_check`.
|
|
73
73
|
3. **Issue the edit directly — in this turn, never in a child.** Load the artifact namespace in your own turn (`tool_search.load_tool_namespace` with paths `["artifact"]`) and call `artifact_edit` with the entry's `slug` and this suggestion handoff (paste the verified staged diff file's full content):
|
|
74
74
|
'Adopt the following change into your source tree, then rebuild and redeploy.
|
|
75
75
|
|
|
@@ -88,11 +88,11 @@ The failures below are settled and recorded in the Gate 1 OODA state. The author
|
|
|
88
88
|
Edit-request contract: call artifact_edit exactly once. Never retry the edit yourself. Tool unavailable (the artifact namespace would not load), a timeout, a transport error, or any otherwise ambiguous result is inconclusive: record NOTHING for this task, log the ambiguity in one line, and let the claim expire; the next scan re-claims and step 2's receipt check decides. An unrecorded outcome is the only honest record of an unknown one.
|
|
89
89
|
After the call returns, inspect the result text. (2026-09-20 REVIEW) Do NOT record an "acknowledged" builder report — the positive builder-report path was circular (the worker knows the version, so the report proves nothing) and is removed; the ack scan reads the version receipt from the artifact's disk as the sole positive evidence. If the result is an explicit refusal → `node {crewHome}/lib/crew-api.js --crew-home {crewHome} record-builder-report --json '{"task_id":"<task_id>","version":"<version>","outcome":"refused","report_text":"<refusal text verbatim>"}'` (the command MECHANICALLY rejects unless `report_text` contains the exact version — if the refusal text lacks the version, do NOT call record-builder-report; just proceed to the record step with `refused`).
|
|
90
90
|
4. **Record the outcome (code):**
|
|
91
|
-
`node {crewHome}/lib/crew-api.js --crew-home {crewHome} record-intent-issuance --json '{"task_id": "<task_id>", "claim_expiry": "<claim_expiry>", "edit_outcome": "<accepted|refused>", "refusal_text": "<first line>"}'`
|
|
92
|
-
(omit `refusal_text` unless refused
|
|
91
|
+
`node {crewHome}/lib/crew-api.js --crew-home {crewHome} record-intent-issuance --json '{"task_id": "<task_id>", "claim_expiry": "<claim_expiry>", "edit_outcome": "<accepted|refused>", "refusal_text": "<first line>", "reclaimed": <true|false>, "receipt_check": "<different|unreadable>"}'`
|
|
92
|
+
(omit `refusal_text` unless refused; copy `reclaimed` verbatim from the scan entry — it is mechanical, never inferred; `receipt_check` is REQUIRED when `reclaimed` is true and must name the exact step-2 outcome — omit `receipt_check` when `reclaimed` is false. A `match` in step 2 never reaches this step: the edit already landed, so you ack instead of issuing.) Copy `claim_expiry` verbatim from the scan entry — it is the compare-and-swap. If the command returns `recorded: false`, log the reason and stop: another tick owns the task. `accepted` writes the issuer-stamped `submitted` ledger entry and mirrors `publish: edit-issued <commit> attempt=<n> version=<commit>:<attempt>` (the 4.5 ack scan claims it on a later tick); `refused` writes `rejected` and the terminal `publish: publish-refused` note. There is no outcome for an inconclusive call (step 3) — do not call record-intent-issuance at all when the edit's fate is unknown.
|
|
93
93
|
- Prose never mints `publish:` notes directly. Any tick-authored publish note goes through exactly one path (code asserts the verb against the closed registry, rejects unknown verbs):
|
|
94
94
|
`node {crewHome}/lib/crew-api.js --crew-home {crewHome} record-publish-note --json '{"task_id":"<task_id>","note":"<full note text>"}'`
|
|
95
|
-
- Crash rules (fail closed, never blind re-issue): if this tick dies between the artifact_edit call and the record step, the next scan re-claims with `reclaimed: true` and step 2's version-receipt check decides between ack (`record-version-ack` with `via: "disk"`) and a fresh issuance. The issuance contract is at-most-one RECORDED issuance per attempt; a duplicate physical issuance across a lease expiry is content-identical (the same checksummed staged diff) and bounded by the ack scan's 2-hour budget.
|
|
95
|
+
- Crash rules (fail closed, never blind re-issue): if this tick dies between the artifact_edit call and the record step, the next scan re-claims with `reclaimed: true` and step 2's version-receipt check decides between ack (`record-version-ack` with `via: "disk"`) and a fresh issuance — recorded with its explicit `receipt_check`, since a differing/unreadable receipt is not proof the prior attempt's edit did not land. The issuance contract is at-most-one RECORDED issuance per attempt; a duplicate physical issuance across a lease expiry is content-identical (the same checksummed staged diff) and bounded by the ack scan's 2-hour budget.
|
|
96
96
|
|
|
97
97
|
4.5. **Acknowledgement scan (version, 0.14.6):** the version acknowledgement is the entire publish verdict — Eric's contract: "If we hear that the artifact acknowledges our version, that's it. We don't verify against content." No content verification, no manifest comparison, no read-back. A dedicated 5-minute `crew-ack-scan` cron runs the same scan on an observation-only tick (it never issues edits) so verdicts land quickly; this tick ALSO runs the scan AFTER 4.4b so an issued edit's verdict path is synchronous. Deterministic code evaluates the evidence and owns every transition; you only log.
|
|
98
98
|
- Scan (code): `node {crewHome}/lib/crew-api.js --crew-home {crewHome} scan-ack-pending`
|
package/workflows/AGENTS.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Executable Muse workflow scripts (JavaScript). These are what the workflow runtime actually runs.
|
|
4
4
|
|
|
5
|
-
- `crew-dispatch.js` — reads the board, recommends eligible tasks, returns structured launch records (the launched workflow self-claims; the dispatcher never writes claims). Skips tasks on quiesced projects and on projects with no repo_path configured.
|
|
5
|
+
- `crew-dispatch.js` — reads the board, recommends eligible tasks, returns structured launch records (the launched workflow self-claims; the dispatcher never writes claims). Skips tasks on quiesced projects and on projects with no repo_path configured. Registry seam (2026-09-21): prefers raw `inputs.registryText` with deterministic `JSON.parse` — never LLM re-emission; loudly falls back to slow registry loading for missing/non-string/malformed input; validates every `reworkTarget` names a real step; the slow-loaded registry is re-validated and fails closed (throw) — no second fallback.
|
|
6
6
|
|
|
7
7
|
All four workflow scripts share byte-identical transport helpers (`workRetryKey`, `buildTransportRetryTrailer`, `describeWorkAgentFailure` — pinned by `tests/closeout.test.js`). The transport-retry loop treats a worker report naming the missing artifact tool namespace (bug 3472bf36, a per-launch platform flake) as a retryable attempt with a fresh launch rather than accepting a useless report.
|
|
8
8
|
|