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.
@@ -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 (orphan-sweep
10
- # already treats it as such) the lease is time-based and refreshed by
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
- LOCK_DIR="${CREW_REPO:-$HOME/workspace/ts-spaces/orchestra-dashboard}/.worktrees"
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="${CREW_HOME:-$HOME/workspace/.jarvis}/.merge-lock.log"
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
@@ -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 (accepted for uniformity; unused here)
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';
@@ -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 ]
@@ -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 "^LOCKED by $TASK since .+ \(pid [0-9]+\)$"; then
63
- ok "lock-status reports LOCKED by <holder> since <ts> (pid <pid>) (exit 0)"
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 ]
@@ -32,6 +32,7 @@ git add -A
32
32
  git commit -qm init
33
33
 
34
34
  export CREW_REPO="$T"
35
+ export CREW_HOME="$T/home" # lifecycle + merge-lock fail closed without both vars
35
36
  tid="verifytest1"
36
37
 
37
38
  # 1. missing branch → ERROR, non-zero