muse-crew 0.6.5 → 0.6.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/AGENTS.md +1 -1
- package/API.md +53 -9
- package/docs/guide.md +9 -6
- package/docs/publish-verification.md +205 -0
- package/lib/AGENTS.md +5 -5
- package/lib/crew-api.js +1101 -0
- package/lib/crew-release.sh +19 -0
- package/lib/merge-lock.sh +18 -5
- package/lib/publish-npm.sh +29 -5
- package/lib/schema.sql +101 -0
- package/lib/test-merge-lock.sh +13 -0
- package/lib/test-publish-skip.sh +5 -3
- package/lib/test-publish-stranded.sh +154 -0
- package/lib/test-terminal-cleanup.sh +131 -0
- package/lib/test-verify-merge.sh +1 -0
- package/lib/test-worktree-backend.sh +74 -0
- package/lib/worktree-lifecycle.sh +165 -17
- package/package.json +2 -2
- package/seed/AGENTS.md +0 -1
- package/seed/crons.json +0 -13
- package/workflows/AGENTS.md +4 -2
- package/workflows/bugfix.js +987 -193
- package/workflows/chore.js +936 -177
- package/workflows/crew-dispatch.js +215 -44
- package/workflows/crew-init.js +82 -54
- package/workflows/docs.js +117 -48
- package/workflows/standard.js +970 -195
- package/workflows/tests/read-board-parse.test.mjs +162 -0
- package/lib/orphan-sweep.sh +0 -287
- package/lib/test-orphan-sweep.sh +0 -276
- package/seed/cron-sweep-body-template.md +0 -11
package/lib/crew-release.sh
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
#
|
|
4
4
|
# Lives at a STABLE path (~/.jarvis/crew-release.sh) outside the release
|
|
5
5
|
# layout so it survives rollback. Init copies it here from the repo.
|
|
6
|
+
# Deploy self-updates it from the repo (syntax-checked) before running,
|
|
7
|
+
# so the stable copy can never go stale behind the repo's logic.
|
|
6
8
|
#
|
|
7
9
|
# Layout:
|
|
8
10
|
# ~/.jarvis/
|
|
@@ -134,6 +136,23 @@ cmd_deploy() {
|
|
|
134
136
|
local home="${2:-$CREW_HOME}"
|
|
135
137
|
CREW_HOME="$home"
|
|
136
138
|
|
|
139
|
+
# Self-update: the stable script is installed once by cmd_init and would
|
|
140
|
+
# otherwise go stale (2026-09-12: the stable copies predated the
|
|
141
|
+
# registry-build block, so deploys silently skipped registry.json).
|
|
142
|
+
# If the repo's lib/crew-release.sh differs from the running script,
|
|
143
|
+
# syntax-check it, install it to the stable path, and re-exec through it
|
|
144
|
+
# so the deploy always runs current logic.
|
|
145
|
+
local repo_script="$repo/lib/crew-release.sh"
|
|
146
|
+
local self_path
|
|
147
|
+
self_path="$(realpath "$0")"
|
|
148
|
+
if [ -f "$repo_script" ] && ! cmp -s "$repo_script" "$self_path"; then
|
|
149
|
+
bash -n "$repo_script" || die "repo's crew-release.sh fails syntax check; refusing to self-update"
|
|
150
|
+
cp -f "$repo_script" "$CREW_HOME/crew-release.sh"
|
|
151
|
+
chmod +x "$CREW_HOME/crew-release.sh"
|
|
152
|
+
echo "SELF-UPDATE: stable crew-release.sh refreshed from repo"
|
|
153
|
+
exec bash "$repo_script" deploy "$repo" "$home"
|
|
154
|
+
fi
|
|
155
|
+
|
|
137
156
|
cd "$repo"
|
|
138
157
|
local hash
|
|
139
158
|
|
package/lib/merge-lock.sh
CHANGED
|
@@ -6,9 +6,8 @@
|
|
|
6
6
|
# that ran the acquire, so for essentially the whole Integrate→Publish→
|
|
7
7
|
# post-deploy hold window the holder's PID was dead, every concurrent
|
|
8
8
|
# acquire took the STALE path, and two Publish phases both believed they
|
|
9
|
-
# owned version assignment. PID is the wrong liveness signal
|
|
10
|
-
#
|
|
11
|
-
# the workflow while the hold is live.
|
|
9
|
+
# owned version assignment. PID is the wrong liveness signal — the lease
|
|
10
|
+
# is time-based and refreshed by the workflow while the hold is live.
|
|
12
11
|
#
|
|
13
12
|
# The lock file ($CREW_REPO/.worktrees/.merge-lock) is key=value:
|
|
14
13
|
# task_id=<task id that holds the lock>
|
|
@@ -29,9 +28,23 @@
|
|
|
29
28
|
|
|
30
29
|
set -euo pipefail
|
|
31
30
|
|
|
32
|
-
|
|
31
|
+
# Fail closed: no stale dev-path defaults. An unset CREW_REPO would
|
|
32
|
+
# operate on another checkout's lock file; an unset CREW_HOME would log
|
|
33
|
+
# to another instance's audit log. Every caller (workflows thread both
|
|
34
|
+
# through LIFECYCLE_ENV; the orphan sweep sets CREW_REPO per repo)
|
|
35
|
+
# provides them.
|
|
36
|
+
if [ -z "${CREW_REPO:-}" ]; then
|
|
37
|
+
echo "BLOCKED: CREW_REPO is not set — refusing to touch a merge lock (a default path would hit the wrong repo)"
|
|
38
|
+
exit 2
|
|
39
|
+
fi
|
|
40
|
+
if [ -z "${CREW_HOME:-}" ]; then
|
|
41
|
+
echo "BLOCKED: CREW_HOME is not set — refusing to touch a merge lock (a default path would log to the wrong instance)"
|
|
42
|
+
exit 2
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
LOCK_DIR="$CREW_REPO/.worktrees"
|
|
33
46
|
LOCK_FILE="$LOCK_DIR/.merge-lock"
|
|
34
|
-
LOG_FILE="$
|
|
47
|
+
LOG_FILE="$CREW_HOME/.merge-lock.log"
|
|
35
48
|
LEASE_SECONDS="${MERGE_LOCK_LEASE_SECONDS:-600}"
|
|
36
49
|
|
|
37
50
|
log_op() { # log_op <op> <detail> — audit trail; logging never breaks a lock op
|
package/lib/publish-npm.sh
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
# REPO_PATH repo checkout (Publish runs here, not in a worktree)
|
|
13
13
|
# PKG npm package name (default: muse-crew)
|
|
14
14
|
# TARGET_VERSION version to publish (workflow-computed, authoritative)
|
|
15
|
-
# CREW_HOME crew instance home (
|
|
15
|
+
# CREW_HOME crew instance home (passed through to the lifecycle
|
|
16
|
+
# seam — the lifecycle and merge-lock fail closed without it)
|
|
16
17
|
# LIFECYCLE pinned worktree-lifecycle.sh
|
|
17
18
|
# RELEASE_SCRIPT crew-release.sh (stable path)
|
|
18
19
|
# NPM_PUBLISH_PY publish helper (default: ~/workspace/skills/npm/bin/npm-publish.py)
|
|
@@ -79,14 +80,14 @@ if [ "$ALREADY_PUBLISHED" = "0" ]; then
|
|
|
79
80
|
# marker prints only on a genuinely unheld lock — a resumed run whose
|
|
80
81
|
# post-deploy already released the lock prints nothing here; its
|
|
81
82
|
# PUBLISHED_ALREADY/PUBLISH_VERIFIED markers already say it all.
|
|
82
|
-
LOCK_STATE="$(CREW_REPO="$REPO_PATH" bash "${LIFECYCLE:?LIFECYCLE is required}" lock-status "$TASK_ID" 2>&1)" \
|
|
83
|
+
LOCK_STATE="$(CREW_HOME="${CREW_HOME:-}" CREW_REPO="$REPO_PATH" bash "${LIFECYCLE:?LIFECYCLE is required}" lock-status "$TASK_ID" 2>&1)" \
|
|
83
84
|
|| fail "lock-status" "$LOCK_STATE"
|
|
84
85
|
if [ "$LOCK_STATE" = "UNLOCKED" ]; then
|
|
85
86
|
NO_LOCK_HELD=1
|
|
86
87
|
echo "PUBLISH_SKIPPED=no-lock-held"
|
|
87
88
|
else
|
|
88
89
|
# 5b. Refresh the merge lock (held from Integrate through post-deploy).
|
|
89
|
-
LOCK_OUT="$(CREW_REPO="$REPO_PATH" bash "${LIFECYCLE:?LIFECYCLE is required}" refresh-lock "$TASK_ID" 2>&1)" \
|
|
90
|
+
LOCK_OUT="$(CREW_HOME="${CREW_HOME:-}" CREW_REPO="$REPO_PATH" bash "${LIFECYCLE:?LIFECYCLE is required}" refresh-lock "$TASK_ID" 2>&1)" \
|
|
90
91
|
|| fail "lock-refresh" "$LOCK_OUT"
|
|
91
92
|
fi
|
|
92
93
|
# STEP-5-END
|
|
@@ -115,6 +116,28 @@ if [ "$ALREADY_PUBLISHED" = "0" ]; then
|
|
|
115
116
|
# empty diff and fails closed at the numstat check.
|
|
116
117
|
# STEP-8-ANCHOR: version write (escape-preserving)
|
|
117
118
|
PKG_JSON="$REPO_PATH/package.json"
|
|
119
|
+
# STRANDED-ANCHOR: resume after a killed publish (release commit made,
|
|
120
|
+
# registry never updated). If the working tree's package.json already
|
|
121
|
+
# carries TARGET_VERSION and HEAD is the script's own release commit for
|
|
122
|
+
# it, the version write is a no-op by design — skip it and let step 9
|
|
123
|
+
# confirm the commit. If HEAD is NOT the release commit, fail closed:
|
|
124
|
+
# a foreign version bump must never be papered over by a publish.
|
|
125
|
+
PRE_VER="$(PKG_JSON="$PKG_JSON" node -p "JSON.parse(require('fs').readFileSync(process.env.PKG_JSON,'utf8')).version")" \
|
|
126
|
+
|| fail "version-write" "could not parse package.json before write"
|
|
127
|
+
STRANDED=0
|
|
128
|
+
if [ "$PRE_VER" = "$TARGET_VERSION" ]; then
|
|
129
|
+
case "$HEAD_MSG" in
|
|
130
|
+
"release: $PKG@$TARGET_VERSION"*)
|
|
131
|
+
STRANDED=1
|
|
132
|
+
echo "PUBLISH_STRANDED_RESUME=1"
|
|
133
|
+
;;
|
|
134
|
+
*)
|
|
135
|
+
fail "version-diff" "package.json already at $TARGET_VERSION but HEAD is not its release commit: $HEAD_MSG"
|
|
136
|
+
;;
|
|
137
|
+
esac
|
|
138
|
+
fi
|
|
139
|
+
# STRANDED-END
|
|
140
|
+
if [ "$STRANDED" = "0" ]; then
|
|
118
141
|
ESCAPED_VER="$(printf '%s' "$TARGET_VERSION" | sed 's/[&\\]/\\&/g')"
|
|
119
142
|
sed -i -E 's/^([[:space:]]*"version"[[:space:]]*:[[:space:]]*)"[^"]*"/\1"'"$ESCAPED_VER"'"/' "$PKG_JSON" \
|
|
120
143
|
|| fail "version-write" "sed could not write $TARGET_VERSION into package.json"
|
|
@@ -129,6 +152,7 @@ if [ "$ALREADY_PUBLISHED" = "0" ]; then
|
|
|
129
152
|
ADD_DEL="$(git -C "$REPO_PATH" diff --numstat -- package.json | tr '\t' ' ')"
|
|
130
153
|
[ "$ADD_DEL" = "1 1 package.json" ] \
|
|
131
154
|
|| fail "version-diff" "expected a single-line version change, got: $ADD_DEL"
|
|
155
|
+
fi
|
|
132
156
|
# STEP-8-END
|
|
133
157
|
|
|
134
158
|
# 9. Commit only if changed (retried run leaves the commit in place).
|
|
@@ -202,7 +226,7 @@ fi
|
|
|
202
226
|
# registry. The push and the post-deploy below must happen under our lock;
|
|
203
227
|
# if the refresh fails (expired lease, someone else holds it), stop — never
|
|
204
228
|
# push a version-bump commit or release a lock we no longer own.
|
|
205
|
-
LOCK_OUT2="$(CREW_REPO="$REPO_PATH" bash "${LIFECYCLE:?LIFECYCLE is required}" refresh-lock "$TASK_ID" 2>&1)" \
|
|
229
|
+
LOCK_OUT2="$(CREW_HOME="${CREW_HOME:-}" CREW_REPO="$REPO_PATH" bash "${LIFECYCLE:?LIFECYCLE is required}" refresh-lock "$TASK_ID" 2>&1)" \
|
|
206
230
|
|| fail "lock-refresh" "$LOCK_OUT2"
|
|
207
231
|
|
|
208
232
|
# 13. Push the version-bump commit (idempotent: no-op if already pushed).
|
|
@@ -211,7 +235,7 @@ PUSH_OUT="$(git -C "$REPO_PATH" push origin main 2>&1)" \
|
|
|
211
235
|
echo "PUBLISH_PUSHED=1"
|
|
212
236
|
|
|
213
237
|
# 14. Finalize: releases the merge lock and cleans up the worktree.
|
|
214
|
-
FIN_OUT="$(CREW_REPO="$REPO_PATH" bash "$LIFECYCLE" post-deploy "$TASK_ID" 2>&1)" \
|
|
238
|
+
FIN_OUT="$(CREW_HOME="${CREW_HOME:-}" CREW_REPO="$REPO_PATH" bash "$LIFECYCLE" post-deploy "$TASK_ID" 2>&1)" \
|
|
215
239
|
|| fail "post-deploy" "$FIN_OUT"
|
|
216
240
|
case "$FIN_OUT" in
|
|
217
241
|
*DEPLOYED*) echo "PUBLISH_FINALIZED=1" ;;
|
package/lib/schema.sql
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
-- Muse Crew state schema.
|
|
2
|
+
--
|
|
3
|
+
-- The crew owns this schema. It is the contract between the crew and any
|
|
4
|
+
-- client (workflows, dashboard, CLIs): every invariant the crew depends on
|
|
5
|
+
-- lives here as a CHECK constraint or index, never in client prose.
|
|
6
|
+
--
|
|
7
|
+
-- Column names are snake_case and match the historical dashboard tables
|
|
8
|
+
-- exactly, so a one-time migration is a straight row copy.
|
|
9
|
+
--
|
|
10
|
+
-- Vocabulary notes:
|
|
11
|
+
-- - Event types include 'rejected': workflows record a Review/QA rejection
|
|
12
|
+
-- as both a session status AND an event type with the same word. A schema
|
|
13
|
+
-- that accepts 'rejected' for sessions but not for events crashes the
|
|
14
|
+
-- workflow mid-run (canary 2026-09-11). One vocabulary, one constraint.
|
|
15
|
+
-- - Task states never include 'blocked': it is derived from unmet
|
|
16
|
+
-- dependencies, never stored.
|
|
17
|
+
|
|
18
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
19
|
+
id TEXT PRIMARY KEY,
|
|
20
|
+
display_name TEXT NOT NULL,
|
|
21
|
+
repo_path TEXT NOT NULL,
|
|
22
|
+
deploy_type TEXT NOT NULL CHECK (deploy_type IN ('npm', 'artifact', 'vercel', '')),
|
|
23
|
+
deploy_slug TEXT,
|
|
24
|
+
description TEXT NOT NULL DEFAULT '',
|
|
25
|
+
simultaneity INTEGER NOT NULL DEFAULT 2 CHECK (simultaneity >= 0 AND simultaneity <= 100),
|
|
26
|
+
quiesced INTEGER NOT NULL DEFAULT 0 CHECK (quiesced IN (0, 1)),
|
|
27
|
+
visual_protocol INTEGER CHECK (visual_protocol IN (0, 1)),
|
|
28
|
+
created_at TEXT NOT NULL,
|
|
29
|
+
updated_at TEXT NOT NULL
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
33
|
+
id TEXT PRIMARY KEY,
|
|
34
|
+
title TEXT NOT NULL CHECK (length(title) >= 1 AND length(title) <= 200),
|
|
35
|
+
description TEXT NOT NULL DEFAULT '',
|
|
36
|
+
state TEXT NOT NULL DEFAULT 'todo'
|
|
37
|
+
CHECK (state IN ('todo', 'in_progress', 'parked', 'done')),
|
|
38
|
+
priority TEXT NOT NULL DEFAULT 'normal'
|
|
39
|
+
CHECK (priority IN ('high', 'normal', 'low')),
|
|
40
|
+
project TEXT NOT NULL DEFAULT 'orchestra-dashboard',
|
|
41
|
+
workflow TEXT,
|
|
42
|
+
next_phase TEXT,
|
|
43
|
+
deps TEXT NOT NULL DEFAULT '[]',
|
|
44
|
+
filed_by TEXT,
|
|
45
|
+
retry_reset_at TEXT,
|
|
46
|
+
created_at TEXT NOT NULL,
|
|
47
|
+
updated_at TEXT NOT NULL
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
CREATE TABLE IF NOT EXISTS poll_state (
|
|
51
|
+
id INTEGER PRIMARY KEY,
|
|
52
|
+
last_poll_at TEXT,
|
|
53
|
+
poll_requested INTEGER NOT NULL DEFAULT 0 CHECK (poll_requested IN (0, 1)),
|
|
54
|
+
min_poll_interval_seconds INTEGER NOT NULL DEFAULT 300
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
CREATE TABLE IF NOT EXISTS config (
|
|
58
|
+
key TEXT PRIMARY KEY,
|
|
59
|
+
value TEXT NOT NULL
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
CREATE TABLE IF NOT EXISTS agent_sessions (
|
|
63
|
+
id TEXT PRIMARY KEY,
|
|
64
|
+
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
65
|
+
identity TEXT NOT NULL,
|
|
66
|
+
step TEXT,
|
|
67
|
+
status TEXT NOT NULL CHECK (status IN
|
|
68
|
+
('running', 'completed', 'failed', 'rejected',
|
|
69
|
+
'timed_out', 'stalled', 'passed', 'superseded')),
|
|
70
|
+
started_at TEXT NOT NULL,
|
|
71
|
+
ended_at TEXT,
|
|
72
|
+
notes TEXT NOT NULL DEFAULT '',
|
|
73
|
+
caveats TEXT NOT NULL DEFAULT '[]',
|
|
74
|
+
failure_reason TEXT,
|
|
75
|
+
last_heartbeat TEXT
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
79
|
+
id TEXT PRIMARY KEY,
|
|
80
|
+
type TEXT NOT NULL CHECK (type IN
|
|
81
|
+
('dispatched', 'completed', 'failed', 'blocked', 'created',
|
|
82
|
+
'note', 'release_activated', 'deployed', 'rejected')),
|
|
83
|
+
task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
|
|
84
|
+
identity TEXT,
|
|
85
|
+
message TEXT NOT NULL CHECK (length(message) >= 1 AND length(message) <= 1000),
|
|
86
|
+
timestamp TEXT NOT NULL
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
CREATE INDEX IF NOT EXISTS projects_display_name_idx ON projects(display_name);
|
|
90
|
+
CREATE INDEX IF NOT EXISTS tasks_state_idx ON tasks(state);
|
|
91
|
+
CREATE INDEX IF NOT EXISTS tasks_updated_at_idx ON tasks(updated_at);
|
|
92
|
+
CREATE INDEX IF NOT EXISTS agent_sessions_status_idx ON agent_sessions(status);
|
|
93
|
+
CREATE INDEX IF NOT EXISTS agent_sessions_task_id_idx ON agent_sessions(task_id);
|
|
94
|
+
CREATE INDEX IF NOT EXISTS events_timestamp_idx ON events(timestamp);
|
|
95
|
+
CREATE INDEX IF NOT EXISTS events_task_id_idx ON events(task_id);
|
|
96
|
+
|
|
97
|
+
-- Atomic claim: at most one running session per task. claim-task inserts a
|
|
98
|
+
-- 'running' session with ON CONFLICT DO NOTHING; zero inserted rows means
|
|
99
|
+
-- another session holds the claim.
|
|
100
|
+
CREATE UNIQUE INDEX IF NOT EXISTS agent_sessions_one_running_per_task_idx
|
|
101
|
+
ON agent_sessions(task_id) WHERE status = 'running';
|
package/lib/test-merge-lock.sh
CHANGED
|
@@ -107,6 +107,19 @@ holder_count=$("$LOCK" status | grep -c '^task_id=racer-' || true)
|
|
|
107
107
|
[ "$holder_count" -eq 1 ] && ok "12: exactly one concurrent acquirer holds the lock" || no "12: holder_count=$holder_count"
|
|
108
108
|
grep -q "result=ACQUIRED" "$TMPBASE/home/.merge-lock.log" && ok "12b: at least one ACQUIRED logged" || no "12b: no ACQUIRED in audit log"
|
|
109
109
|
|
|
110
|
+
# --- Case 13: fail closed on unset CREW_REPO / CREW_HOME ---
|
|
111
|
+
# No stale dev-path defaults: any op without both vars prints BLOCKED
|
|
112
|
+
# and exits 2 instead of touching the wrong repo's lock file.
|
|
113
|
+
out=$(env -u CREW_REPO "$LOCK" status 2>&1); code=$?
|
|
114
|
+
[ "$code" -eq 2 ] && [[ "$out" == *"BLOCKED"* ]] && [[ "$out" == *"CREW_REPO"* ]] \
|
|
115
|
+
&& ok "13a: unset CREW_REPO exits 2 with BLOCKED" || no "13a: exit $code, out: $out"
|
|
116
|
+
out=$(env -u CREW_HOME "$LOCK" status 2>&1); code=$?
|
|
117
|
+
[ "$code" -eq 2 ] && [[ "$out" == *"BLOCKED"* ]] && [[ "$out" == *"CREW_HOME"* ]] \
|
|
118
|
+
&& ok "13b: unset CREW_HOME exits 2 with BLOCKED" || no "13b: exit $code, out: $out"
|
|
119
|
+
out=$(env -u CREW_REPO -u CREW_HOME "$LOCK" acquire task-x "task-x/run" 2>&1); code=$?
|
|
120
|
+
[ "$code" -eq 2 ] && [[ "$out" == *"BLOCKED"* ]] \
|
|
121
|
+
&& ok "13c: unset both on acquire exits 2 with BLOCKED" || no "13c: exit $code, out: $out"
|
|
122
|
+
|
|
110
123
|
echo ""
|
|
111
124
|
echo "merge-lock: $pass passed, $fail failed"
|
|
112
125
|
[ "$fail" -eq 0 ]
|
package/lib/test-publish-skip.sh
CHANGED
|
@@ -43,6 +43,7 @@ echo x > "$SCRATCH/f.txt"
|
|
|
43
43
|
git -C "$SCRATCH" add -A && git -C "$SCRATCH" commit -qm init
|
|
44
44
|
|
|
45
45
|
export CREW_REPO="$SCRATCH" CREW_LIB="$SCRIPT_DIR"
|
|
46
|
+
export CREW_HOME="$TMPBASE/home" # lifecycle + merge-lock fail closed without both vars
|
|
46
47
|
|
|
47
48
|
TASK="skiptest-task-1"
|
|
48
49
|
|
|
@@ -59,8 +60,9 @@ fi
|
|
|
59
60
|
bash "$MERGE_LOCK" acquire "$TASK" "$$" >/dev/null
|
|
60
61
|
OUT="$(bash "$LIFECYCLE" lock-status "$TASK" 2>&1)"
|
|
61
62
|
CODE=$?
|
|
62
|
-
if [ $CODE -eq 0 ] && printf '%s' "$OUT" | grep -Eq "^
|
|
63
|
-
|
|
63
|
+
if [ $CODE -eq 0 ] && printf '%s' "$OUT" | grep -Eq "^locked=true$" \
|
|
64
|
+
&& printf '%s' "$OUT" | grep -Eq "^task_id=$TASK$"; then
|
|
65
|
+
ok "lock-status reports key=value (locked=true, task_id=$TASK) (exit 0)"
|
|
64
66
|
else
|
|
65
67
|
no "lock-status LOCKED: code=$CODE out='$OUT'"
|
|
66
68
|
fi
|
|
@@ -105,7 +107,7 @@ run_block() { # run_block <lock_setup: none|held|broken>
|
|
|
105
107
|
esac
|
|
106
108
|
(
|
|
107
109
|
set -euo pipefail
|
|
108
|
-
export CREW_REPO="$SCRATCH" TASK_ID="$TASK" REPO_PATH="$SCRATCH"
|
|
110
|
+
export CREW_REPO="$SCRATCH" TASK_ID="$TASK" REPO_PATH="$SCRATCH" CREW_HOME="$TMPBASE/home"
|
|
109
111
|
if [ "$mode" = "broken" ]; then
|
|
110
112
|
export LIFECYCLE="/nonexistent/lifecycle.sh"
|
|
111
113
|
else
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# test-publish-stranded.sh — regression test for the stranded-release resume
|
|
3
|
+
# in lib/publish-npm.sh (park 2026-09-11).
|
|
4
|
+
#
|
|
5
|
+
# A service restart can kill a Publish run between the release commit and the
|
|
6
|
+
# registry PUT. The release commit is pushed, but npm never got the tarball.
|
|
7
|
+
# Replaying the script must not fail the version-diff guard (the write is a
|
|
8
|
+
# no-op because package.json already carries the target): the script detects
|
|
9
|
+
# the stranded state and resumes at pack/publish. A package.json already at
|
|
10
|
+
# the target whose HEAD is NOT the script's own release commit is a foreign
|
|
11
|
+
# version bump and must fail closed.
|
|
12
|
+
#
|
|
13
|
+
# Self-contained: extracts the shipped STRANDED block from lib/publish-npm.sh
|
|
14
|
+
# by anchor and runs it against scratch checkouts.
|
|
15
|
+
#
|
|
16
|
+
# Anchors: `# STRANDED-ANCHOR` ... `# STRANDED-END` in lib/publish-npm.sh.
|
|
17
|
+
|
|
18
|
+
set -uo pipefail
|
|
19
|
+
|
|
20
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
21
|
+
PUBLISH="$SCRIPT_DIR/publish-npm.sh"
|
|
22
|
+
|
|
23
|
+
PKG="muse-crew"
|
|
24
|
+
TARGET_VERSION="9.9.9"
|
|
25
|
+
STRANDED_MSG="release: muse-crew@9.9.9 (task-aaa)"
|
|
26
|
+
STRANDED_MSG_BARE="release: muse-crew@9.9.9"
|
|
27
|
+
|
|
28
|
+
pass=0
|
|
29
|
+
fail_count=0
|
|
30
|
+
ok() { echo "PASS: $1"; pass=$((pass + 1)); }
|
|
31
|
+
no() { echo "FAIL: $1"; fail_count=$((fail_count + 1)); }
|
|
32
|
+
|
|
33
|
+
TMPBASE="$(mktemp -d)"
|
|
34
|
+
trap 'rm -rf "$TMPBASE"' EXIT
|
|
35
|
+
|
|
36
|
+
# --- Extract the shipped STRANDED block by anchor ---
|
|
37
|
+
BLOCK="$TMPBASE/stranded.sh"
|
|
38
|
+
sed -n '/# STRANDED-ANCHOR/,/# STRANDED-END/p' "$PUBLISH" > "$BLOCK"
|
|
39
|
+
if grep -q 'PUBLISH_STRANDED_RESUME=1' "$BLOCK" && grep -q 'fail "version-diff"' "$BLOCK"; then
|
|
40
|
+
ok "extracted STRANDED block from publish-npm.sh"
|
|
41
|
+
else
|
|
42
|
+
no "STRANDED extraction failed (anchors missing)"
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
# make_checkout <version> <head-msg>: a scratch git checkout whose
|
|
46
|
+
# package.json carries <version> and whose HEAD message is <head-msg>.
|
|
47
|
+
make_checkout() {
|
|
48
|
+
local ver="$1" msg="$2" dir="$TMPBASE/checkout-$RANDOM"
|
|
49
|
+
mkdir -p "$dir"
|
|
50
|
+
git -C "$dir" init -q
|
|
51
|
+
git -C "$dir" config user.email "test@test"
|
|
52
|
+
git -C "$dir" config user.name "test"
|
|
53
|
+
printf '{\n "name": "muse-crew",\n "version": "%s"\n}\n' "$ver" > "$dir/package.json"
|
|
54
|
+
git -C "$dir" add package.json
|
|
55
|
+
git -C "$dir" commit -q -m "$msg"
|
|
56
|
+
printf '%s' "$dir"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# run_stranded <checkout-dir> <head-msg>: runs the shipped block with
|
|
60
|
+
# PKG_JSON/PKG/TARGET_VERSION/HEAD_MSG set; captures stdout in $OUT.
|
|
61
|
+
run_stranded() {
|
|
62
|
+
local dir="$1" msg="$2" runner="$TMPBASE/runner-$RANDOM.sh"
|
|
63
|
+
{
|
|
64
|
+
echo 'fail() { echo "PUBLISH_FAILED=$1"; shift; echo "$1"; exit 1; }'
|
|
65
|
+
cat "$BLOCK"
|
|
66
|
+
echo 'echo "STRANDED_RESULT=$STRANDED"'
|
|
67
|
+
} > "$runner"
|
|
68
|
+
chmod +x "$runner"
|
|
69
|
+
OUT="$(PKG_JSON="$dir/package.json" PKG="$PKG" \
|
|
70
|
+
TARGET_VERSION="$TARGET_VERSION" HEAD_MSG="$msg" \
|
|
71
|
+
bash "$runner" 2>&1)"
|
|
72
|
+
local code=$?
|
|
73
|
+
echo "$OUT"
|
|
74
|
+
return "$code"
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# --- Case 1: stranded release commit (with task id) — resume, no write ---
|
|
78
|
+
DIR1="$(make_checkout "$TARGET_VERSION" "$STRANDED_MSG")"
|
|
79
|
+
BEFORE1="$(cat "$DIR1/package.json")"
|
|
80
|
+
OUT="$(run_stranded "$DIR1" "$STRANDED_MSG")"
|
|
81
|
+
CODE=$?
|
|
82
|
+
if [ "$CODE" -eq 0 ]; then
|
|
83
|
+
ok "stranded resume: block exits 0"
|
|
84
|
+
else
|
|
85
|
+
no "stranded resume: block exited $CODE (output: $OUT)"
|
|
86
|
+
fi
|
|
87
|
+
case "$OUT" in
|
|
88
|
+
*"PUBLISH_STRANDED_RESUME=1"*)
|
|
89
|
+
ok "stranded resume: prints PUBLISH_STRANDED_RESUME=1" ;;
|
|
90
|
+
*) no "stranded resume: missing PUBLISH_STRANDED_RESUME=1 (output: $OUT)" ;;
|
|
91
|
+
esac
|
|
92
|
+
case "$OUT" in
|
|
93
|
+
*"STRANDED_RESULT=1"*)
|
|
94
|
+
ok "stranded resume: STRANDED=1 (skips the version write)" ;;
|
|
95
|
+
*) no "stranded resume: expected STRANDED=1 (output: $OUT)" ;;
|
|
96
|
+
esac
|
|
97
|
+
if [ "$(cat "$DIR1/package.json")" = "$BEFORE1" ]; then
|
|
98
|
+
ok "stranded resume: package.json untouched (write skipped)"
|
|
99
|
+
else
|
|
100
|
+
no "stranded resume: package.json was modified"
|
|
101
|
+
fi
|
|
102
|
+
|
|
103
|
+
# --- Case 2: bare release message form (no task id) — also resumes ---
|
|
104
|
+
DIR2="$(make_checkout "$TARGET_VERSION" "$STRANDED_MSG_BARE")"
|
|
105
|
+
OUT="$(run_stranded "$DIR2" "$STRANDED_MSG_BARE")"
|
|
106
|
+
CODE=$?
|
|
107
|
+
if [ "$CODE" -eq 0 ] && grep -q 'PUBLISH_STRANDED_RESUME=1' <<< "$OUT"; then
|
|
108
|
+
ok "bare release message form also resumes"
|
|
109
|
+
else
|
|
110
|
+
no "bare release message form failed to resume (code $CODE, output: $OUT)"
|
|
111
|
+
fi
|
|
112
|
+
|
|
113
|
+
# --- Case 3: foreign version bump — fail closed ---
|
|
114
|
+
DIR3="$(make_checkout "$TARGET_VERSION" "merge: some other fix")"
|
|
115
|
+
OUT="$(run_stranded "$DIR3" "merge: some other fix")"
|
|
116
|
+
CODE=$?
|
|
117
|
+
if [ "$CODE" -ne 0 ]; then
|
|
118
|
+
ok "foreign version: block fails closed (non-zero exit)"
|
|
119
|
+
else
|
|
120
|
+
no "foreign version: block exited 0 despite foreign HEAD"
|
|
121
|
+
fi
|
|
122
|
+
case "$OUT" in
|
|
123
|
+
*"PUBLISH_FAILED=version-diff"*)
|
|
124
|
+
ok "foreign version: keeps the PUBLISH_FAILED=version-diff marker" ;;
|
|
125
|
+
*) no "foreign version: missing PUBLISH_FAILED=version-diff (output: $OUT)" ;;
|
|
126
|
+
esac
|
|
127
|
+
case "$OUT" in
|
|
128
|
+
*"not its release commit"*)
|
|
129
|
+
ok "foreign version: detail names the mismatch" ;;
|
|
130
|
+
*) no "foreign version: missing mismatch detail (output: $OUT)" ;;
|
|
131
|
+
esac
|
|
132
|
+
|
|
133
|
+
# --- Case 4: normal path (package.json behind) — no resume, write proceeds ---
|
|
134
|
+
DIR4="$(make_checkout "9.9.8" "merge: some other fix")"
|
|
135
|
+
OUT="$(run_stranded "$DIR4" "merge: some other fix")"
|
|
136
|
+
CODE=$?
|
|
137
|
+
if [ "$CODE" -eq 0 ]; then
|
|
138
|
+
ok "normal path: block exits 0"
|
|
139
|
+
else
|
|
140
|
+
no "normal path: block exited $CODE (output: $OUT)"
|
|
141
|
+
fi
|
|
142
|
+
case "$OUT" in
|
|
143
|
+
*"STRANDED_RESULT=0"*)
|
|
144
|
+
ok "normal path: STRANDED=0 (version write proceeds)" ;;
|
|
145
|
+
*) no "normal path: expected STRANDED=0 (output: $OUT)" ;;
|
|
146
|
+
esac
|
|
147
|
+
case "$OUT" in
|
|
148
|
+
*"PUBLISH_STRANDED_RESUME=1"*) no "normal path: must not print PUBLISH_STRANDED_RESUME=1" ;;
|
|
149
|
+
*) ok "normal path: no stranded-resume marker" ;;
|
|
150
|
+
esac
|
|
151
|
+
|
|
152
|
+
echo ""
|
|
153
|
+
echo "$pass passed, $fail_count failed."
|
|
154
|
+
[ "$fail_count" -eq 0 ]
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# test-terminal-cleanup.sh — regression tests for the terminal-cleanup
|
|
3
|
+
# lifecycle command (the run's last act at every park/fail boundary).
|
|
4
|
+
#
|
|
5
|
+
# Self-contained: builds throwaway git fixtures in a temp dir and drives
|
|
6
|
+
# lib/worktree-lifecycle.sh terminal-cleanup directly (no network, no
|
|
7
|
+
# dashboard). Exits 0 only if every case passes.
|
|
8
|
+
#
|
|
9
|
+
# Contract under test:
|
|
10
|
+
# - the merge lock is released unconditionally (even when there is
|
|
11
|
+
# nothing else to reclaim);
|
|
12
|
+
# - a fully-merged task branch is reclaimed: worktree removed, branch
|
|
13
|
+
# deleted, registry unregistered, CLEANED: true;
|
|
14
|
+
# - a branch holding unmerged commits is PRESERVED (dir, branch, and
|
|
15
|
+
# registry all intact), CLEANED: false;
|
|
16
|
+
# - a missing branch is a no-op (BRANCH: absent), exit 0;
|
|
17
|
+
# - a dirty worktree is never force-removed: the failure is reported
|
|
18
|
+
# loudly (WORKTREE: remove-failed), the registry is kept, and
|
|
19
|
+
# CLEANED: false.
|
|
20
|
+
|
|
21
|
+
set -uo pipefail
|
|
22
|
+
|
|
23
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
24
|
+
LIFECYCLE="$SCRIPT_DIR/worktree-lifecycle.sh"
|
|
25
|
+
|
|
26
|
+
pass=0
|
|
27
|
+
fail=0
|
|
28
|
+
ok() { echo "PASS: $1"; pass=$((pass + 1)); }
|
|
29
|
+
no() { echo "FAIL: $1"; fail=$((fail + 1)); }
|
|
30
|
+
|
|
31
|
+
TMPBASE="/home/hatch/workspace/.tmp-terminal-cleanup-test-$$"
|
|
32
|
+
rm -rf "$TMPBASE"
|
|
33
|
+
mkdir -p "$TMPBASE"
|
|
34
|
+
trap 'rm -rf "$TMPBASE"' EXIT
|
|
35
|
+
|
|
36
|
+
# Keep merge-lock.sh's audit log out of the real $CREW_HOME during tests,
|
|
37
|
+
# and point the lifecycle seam at this repo's lib/.
|
|
38
|
+
export CREW_HOME="$TMPBASE/home"
|
|
39
|
+
export CREW_LIB="$SCRIPT_DIR"
|
|
40
|
+
# The sweep resolves crew-api.js from the instance ($CREW_HOME/current/lib);
|
|
41
|
+
# this fixture home has no release layout, so point it at this repo's copy.
|
|
42
|
+
export CREW_API="$SCRIPT_DIR/crew-api.js"
|
|
43
|
+
mkdir -p "$CREW_HOME"
|
|
44
|
+
|
|
45
|
+
# new_fixture <name> — fresh git repo, one commit on main; exports CREW_REPO.
|
|
46
|
+
new_fixture() {
|
|
47
|
+
local dir="$TMPBASE/$1"
|
|
48
|
+
mkdir -p "$dir"
|
|
49
|
+
git init -q -b main "$dir" >/dev/null
|
|
50
|
+
git -C "$dir" config user.email "test@example.com"
|
|
51
|
+
git -C "$dir" config user.name "test"
|
|
52
|
+
echo base > "$dir/seed.txt"
|
|
53
|
+
git -C "$dir" add seed.txt
|
|
54
|
+
git -C "$dir" commit -qm "initial"
|
|
55
|
+
export CREW_REPO="$dir"
|
|
56
|
+
cd "$dir" || exit 1
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# register_project <slug> — register the current fixture repo as a crew
|
|
60
|
+
# project (the sweep enumerates repos from the crew DB, never CREW_REPO).
|
|
61
|
+
register_project() {
|
|
62
|
+
node "$SCRIPT_DIR/crew-api.js" --crew-home "$CREW_HOME" create-project \
|
|
63
|
+
--json "{\"id\":\"$1\",\"display_name\":\"$1\",\"repo_path\":\"$CREW_REPO\"}" >/dev/null
|
|
64
|
+
}
|
|
65
|
+
# make_worktree <task_id> — worktree on task/<task_id> with one commit,
|
|
66
|
+
# plus a registry entry (the way prepare would leave it).
|
|
67
|
+
make_worktree() {
|
|
68
|
+
local t="$1"
|
|
69
|
+
git worktree add -q ".worktrees/$t" -b "task/$t" >/dev/null
|
|
70
|
+
echo "$t" > ".worktrees/$t/work.txt"
|
|
71
|
+
git -C ".worktrees/$t" add work.txt
|
|
72
|
+
git -C ".worktrees/$t" commit -qm "work for $t"
|
|
73
|
+
mkdir -p ".worktrees/.registry"
|
|
74
|
+
printf 'branch=task/%s\npath=%s/.worktrees/%s\n' "$t" "$CREW_REPO" "$t" > ".worktrees/.registry/$t"
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# --- Case 1: merged branch is reclaimed and the lock is released ---
|
|
78
|
+
new_fixture case1
|
|
79
|
+
make_worktree t1
|
|
80
|
+
git merge -q --no-ff "task/t1" -m "merge t1" >/dev/null
|
|
81
|
+
# Hold the merge lock the way a run would.
|
|
82
|
+
"$SCRIPT_DIR/merge-lock.sh" acquire t1 "t1/holder" >/dev/null
|
|
83
|
+
out=$(CREW_REPO="$CREW_REPO" CREW_HOME="$CREW_HOME" CREW_LIB="$SCRIPT_DIR" bash "$LIFECYCLE" terminal-cleanup t1 2>&1)
|
|
84
|
+
[ ! -d ".worktrees/t1" ] && ! git show-ref --verify --quiet "refs/heads/task/t1" \
|
|
85
|
+
&& [ ! -f ".worktrees/.registry/t1" ] \
|
|
86
|
+
&& [[ "$out" == *"CLEANED: true"* ]] \
|
|
87
|
+
&& [[ "$out" == *"LOCK: released"* ]] \
|
|
88
|
+
&& [ "$("$SCRIPT_DIR/merge-lock.sh" status)" = "UNLOCKED" ] \
|
|
89
|
+
&& ok "case1: merged branch reclaimed, registry unregistered, lock released" \
|
|
90
|
+
|| no "case1: merged branch reclaimed (out: $out)"
|
|
91
|
+
|
|
92
|
+
# --- Case 2: unmerged branch is preserved ---
|
|
93
|
+
new_fixture case2
|
|
94
|
+
make_worktree t2
|
|
95
|
+
# NOTE: no merge — the branch holds one commit ahead of main.
|
|
96
|
+
"$SCRIPT_DIR/merge-lock.sh" acquire t2 "t2/holder" >/dev/null
|
|
97
|
+
out=$(CREW_REPO="$CREW_REPO" CREW_HOME="$CREW_HOME" CREW_LIB="$SCRIPT_DIR" bash "$LIFECYCLE" terminal-cleanup t2 2>&1)
|
|
98
|
+
[ -d ".worktrees/t2" ] && git show-ref --verify --quiet "refs/heads/task/t2" \
|
|
99
|
+
&& [ -f ".worktrees/.registry/t2" ] \
|
|
100
|
+
&& [[ "$out" == *"PRESERVED"* ]] \
|
|
101
|
+
&& [[ "$out" == *"CLEANED: false"* ]] \
|
|
102
|
+
&& [ "$("$SCRIPT_DIR/merge-lock.sh" status)" = "UNLOCKED" ] \
|
|
103
|
+
&& ok "case2: unmerged branch preserved, lock still released" \
|
|
104
|
+
|| no "case2: unmerged branch preserved (out: $out)"
|
|
105
|
+
|
|
106
|
+
# --- Case 3: missing branch is a no-op ---
|
|
107
|
+
new_fixture case3
|
|
108
|
+
out=$(CREW_REPO="$CREW_REPO" CREW_HOME="$CREW_HOME" CREW_LIB="$SCRIPT_DIR" bash "$LIFECYCLE" terminal-cleanup t3 2>&1)
|
|
109
|
+
rc=$?
|
|
110
|
+
[[ "$out" == *"BRANCH: absent"* ]] && [ "$rc" -eq 0 ] \
|
|
111
|
+
&& ok "case3: missing branch is a no-op, exit 0" \
|
|
112
|
+
|| no "case3: missing branch no-op (rc=$rc out: $out)"
|
|
113
|
+
|
|
114
|
+
# --- Case 4: dirty worktree is never force-removed ---
|
|
115
|
+
new_fixture case4
|
|
116
|
+
make_worktree t4
|
|
117
|
+
git merge -q --no-ff "task/t4" -m "merge t4" >/dev/null
|
|
118
|
+
echo "uncommitted" > ".worktrees/t4/dirty.txt"
|
|
119
|
+
out=$(CREW_REPO="$CREW_REPO" CREW_HOME="$CREW_HOME" CREW_LIB="$SCRIPT_DIR" bash "$LIFECYCLE" terminal-cleanup t4 2>&1)
|
|
120
|
+
[ -d ".worktrees/t4" ] && git show-ref --verify --quiet "refs/heads/task/t4" \
|
|
121
|
+
&& [ -f ".worktrees/.registry/t4" ] \
|
|
122
|
+
&& [ -f ".worktrees/t4/dirty.txt" ] \
|
|
123
|
+
&& [[ "$out" == *"WORKTREE: remove-failed"* ]] \
|
|
124
|
+
&& [[ "$out" == *"CLEANED: false"* ]] \
|
|
125
|
+
&& [[ "$out" != *"WORKTREE: removed"* ]] \
|
|
126
|
+
&& ok "case4: dirty worktree reported loudly, never force-removed" \
|
|
127
|
+
|| no "case4: dirty worktree honesty (out: $out)"
|
|
128
|
+
|
|
129
|
+
echo ""
|
|
130
|
+
echo "terminal-cleanup: $pass passed, $fail failed"
|
|
131
|
+
[ "$fail" -eq 0 ]
|