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.
@@ -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
@@ -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';
@@ -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
 
@@ -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 ]
@@ -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
@@ -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"