muse-crew 0.6.6 → 0.6.8
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 +6 -5
- package/lib/schema.sql +101 -0
- package/lib/test-merge-lock.sh +13 -0
- package/lib/test-publish-skip.sh +2 -1
- 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 +164 -16
- package/package.json +2 -2
- package/seed/AGENTS.md +0 -1
- package/seed/cron-body-template.md +2 -2
- 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 +201 -37
- package/workflows/crew-init.js +85 -50
- 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
|
|
@@ -225,7 +226,7 @@ fi
|
|
|
225
226
|
# registry. The push and the post-deploy below must happen under our lock;
|
|
226
227
|
# if the refresh fails (expired lease, someone else holds it), stop — never
|
|
227
228
|
# push a version-bump commit or release a lock we no longer own.
|
|
228
|
-
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)" \
|
|
229
230
|
|| fail "lock-refresh" "$LOCK_OUT2"
|
|
230
231
|
|
|
231
232
|
# 13. Push the version-bump commit (idempotent: no-op if already pushed).
|
|
@@ -234,7 +235,7 @@ PUSH_OUT="$(git -C "$REPO_PATH" push origin main 2>&1)" \
|
|
|
234
235
|
echo "PUBLISH_PUSHED=1"
|
|
235
236
|
|
|
236
237
|
# 14. Finalize: releases the merge lock and cleans up the worktree.
|
|
237
|
-
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)" \
|
|
238
239
|
|| fail "post-deploy" "$FIN_OUT"
|
|
239
240
|
case "$FIN_OUT" in
|
|
240
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
|
|
|
@@ -106,7 +107,7 @@ run_block() { # run_block <lock_setup: none|held|broken>
|
|
|
106
107
|
esac
|
|
107
108
|
(
|
|
108
109
|
set -euo pipefail
|
|
109
|
-
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"
|
|
110
111
|
if [ "$mode" = "broken" ]; then
|
|
111
112
|
export LIFECYCLE="/nonexistent/lifecycle.sh"
|
|
112
113
|
else
|
|
@@ -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 ]
|
package/lib/test-verify-merge.sh
CHANGED
|
@@ -24,6 +24,7 @@ git add -A
|
|
|
24
24
|
git commit -qm init
|
|
25
25
|
|
|
26
26
|
export CREW_REPO="$T"
|
|
27
|
+
export CREW_HOME="$T/home" # lifecycle + merge-lock fail closed without both vars
|
|
27
28
|
tid="backendtest1"
|
|
28
29
|
|
|
29
30
|
# validate: good id passes, bad id is rejected
|
|
@@ -75,5 +76,78 @@ else
|
|
|
75
76
|
fi
|
|
76
77
|
git -C "$T" checkout -q -- f.txt
|
|
77
78
|
|
|
79
|
+
|
|
80
|
+
# integrate reconciles a diverged remote under the merge lock (canary
|
|
81
|
+
# a6d8b0c8, 2026-09-11): remote main advanced outside the lock; without the
|
|
82
|
+
# reconcile the agent's push fails non-fast-forward.
|
|
83
|
+
T2=/tmp/crew-git-integrate-test
|
|
84
|
+
rm -rf "$T2"
|
|
85
|
+
mkdir -p "$T2"
|
|
86
|
+
git init -q --bare "$T2/remote.git"
|
|
87
|
+
git clone -q "$T2/remote.git" "$T2/repo"
|
|
88
|
+
git -C "$T2/repo" config user.email test@test.t
|
|
89
|
+
git -C "$T2/repo" config user.name test
|
|
90
|
+
echo base > "$T2/repo/f.txt"
|
|
91
|
+
echo ".worktrees/" > "$T2/repo/.gitignore"
|
|
92
|
+
git -C "$T2/repo" add -A
|
|
93
|
+
git -C "$T2/repo" commit -qm base
|
|
94
|
+
git -C "$T2/repo" push -q -u origin main
|
|
95
|
+
git -C "$T2/remote.git" symbolic-ref HEAD refs/heads/main
|
|
96
|
+
|
|
97
|
+
export CREW_REPO="$T2/repo"
|
|
98
|
+
export CREW_LIB="$REPO_DIR/lib"
|
|
99
|
+
|
|
100
|
+
out=$(bash "$LIFECYCLE" prepare "int1") || fail "integrate-test: prepare failed: $out"
|
|
101
|
+
echo taskwork >> "$T2/repo/.worktrees/int1/f.txt"
|
|
102
|
+
(git -C "$T2/repo/.worktrees/int1" commit -qam taskwork) || fail "integrate-test: task commit failed"
|
|
103
|
+
|
|
104
|
+
# Another writer lands a commit on origin/main outside the merge lock.
|
|
105
|
+
git clone -q "$T2/remote.git" "$T2/other"
|
|
106
|
+
git -C "$T2/other" config user.email test@test.t
|
|
107
|
+
git -C "$T2/other" config user.name test
|
|
108
|
+
echo other > "$T2/other/other.txt"
|
|
109
|
+
git -C "$T2/other" add -A
|
|
110
|
+
git -C "$T2/other" commit -qm otherwriter
|
|
111
|
+
git -C "$T2/other" push -q origin main
|
|
112
|
+
|
|
113
|
+
# Fixture sensitivity: without the reconcile, the push would be rejected.
|
|
114
|
+
git -C "$T2/repo" fetch -q origin
|
|
115
|
+
if git -C "$T2/repo" push --dry-run origin main >/dev/null 2>&1; then
|
|
116
|
+
fail "integrate-test: fixture not divergent — push dry-run succeeded before integrate"
|
|
117
|
+
fi
|
|
118
|
+
|
|
119
|
+
out=$(bash "$LIFECYCLE" integrate "int1" "merge: int1") || fail "integrate-test: integrate failed: $out"
|
|
120
|
+
echo "$out" | grep -q '^MERGED: ' || fail "integrate-test: no MERGED line: $out"
|
|
121
|
+
echo "$out" | grep -q '^RECONCILED: ' || fail "integrate-test: no RECONCILED line: $out"
|
|
122
|
+
# The other writer's commit is now an ancestor of local main.
|
|
123
|
+
git -C "$T2/repo" merge-base --is-ancestor origin/main main || fail "integrate-test: origin/main not merged into main"
|
|
124
|
+
# The subsequent push is a fast-forward.
|
|
125
|
+
git -C "$T2/repo" push --dry-run origin main >/dev/null 2>&1 || fail "integrate-test: push still non-fast-forward after reconcile"
|
|
126
|
+
git -C "$T2/repo" push -q origin main || fail "integrate-test: push failed"
|
|
127
|
+
# Lock is held by int1 until post-deploy releases it (release is holder-keyed; it fails for non-holders).
|
|
128
|
+
"$CREW_LIB/merge-lock.sh" release "int1" >/dev/null || fail "integrate-test: lock release failed"
|
|
129
|
+
|
|
130
|
+
# integrate with no remote: fail-soft (NO_REMOTE), merge still happens.
|
|
131
|
+
T3=/tmp/crew-git-integrate-noremote
|
|
132
|
+
rm -rf "$T3"
|
|
133
|
+
mkdir -p "$T3"
|
|
134
|
+
git init -q -b main "$T3/repo"
|
|
135
|
+
git -C "$T3/repo" config user.email test@test.t
|
|
136
|
+
git -C "$T3/repo" config user.name test
|
|
137
|
+
echo base > "$T3/repo/f.txt"
|
|
138
|
+
echo ".worktrees/" > "$T3/repo/.gitignore"
|
|
139
|
+
git -C "$T3/repo" add -A
|
|
140
|
+
git -C "$T3/repo" commit -qm base
|
|
141
|
+
export CREW_REPO="$T3/repo"
|
|
142
|
+
out=$(bash "$LIFECYCLE" prepare "int2") || fail "integrate-test: prepare (no remote) failed: $out"
|
|
143
|
+
echo taskwork >> "$T3/repo/.worktrees/int2/f.txt"
|
|
144
|
+
(git -C "$T3/repo/.worktrees/int2" commit -qam taskwork) || fail "integrate-test: task commit (no remote) failed"
|
|
145
|
+
out=$(bash "$LIFECYCLE" integrate "int2" "merge: int2") || fail "integrate-test: integrate (no remote) failed: $out"
|
|
146
|
+
echo "$out" | grep -q '^MERGED: ' || fail "integrate-test: no MERGED line (no remote): $out"
|
|
147
|
+
echo "$out" | grep -q '^NO_REMOTE: ' || fail "integrate-test: no NO_REMOTE line: $out"
|
|
148
|
+
"$CREW_LIB/merge-lock.sh" release "int2" >/dev/null || fail "integrate-test: lock release (no remote) failed"
|
|
149
|
+
|
|
150
|
+
rm -rf "$T2" "$T3"
|
|
151
|
+
export CREW_REPO="$T"
|
|
78
152
|
rm -rf "$T"
|
|
79
153
|
echo "worktree-backend: OK"
|