@osfactory/har 0.6.0 → 0.7.0

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.
@@ -26,6 +26,337 @@ validate_agent_id() {
26
26
  fi
27
27
  }
28
28
 
29
+ # ── Port allocation ─────────────────────────────────────────────────────────────
30
+ # Try the configured default first; scan the slot lane or infra range when busy.
31
+
32
+ port_in_use() {
33
+ local port="$1"
34
+ (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null && { exec 3>&- || true; return 0; }
35
+ return 1
36
+ }
37
+
38
+ har_port_step() {
39
+ echo "${HARNESS_PORT_STEP:-10}"
40
+ }
41
+
42
+ har_default_app_port() {
43
+ local base="$1"
44
+ local agent_id="$2"
45
+ echo $(( base + agent_id * $(har_port_step) ))
46
+ }
47
+
48
+ har_slot_port_lane_end() {
49
+ local default_port="$1"
50
+ echo $(( default_port + $(har_port_step) - 1 ))
51
+ }
52
+
53
+ # har_pick_free_port <start> <end> — echoes the first free port in [start, end].
54
+ har_pick_free_port() {
55
+ local start="$1"
56
+ local end="$2"
57
+ local port
58
+ for ((port=start; port<=end; port++)); do
59
+ if ! port_in_use "$port"; then
60
+ echo "$port"
61
+ return 0
62
+ fi
63
+ done
64
+ echo "Error: no free port in range ${start}-${end}" >&2
65
+ return 1
66
+ }
67
+
68
+ # har_allocate_port <default> <scan_start> <scan_end>
69
+ har_allocate_port() {
70
+ local default_port="$1"
71
+ local scan_start="$2"
72
+ local scan_end="$3"
73
+ if ! port_in_use "$default_port"; then
74
+ echo "$default_port"
75
+ return 0
76
+ fi
77
+ har_pick_free_port "$scan_start" "$scan_end"
78
+ }
79
+
80
+ # Allocate FE/API/DEBUG ports for a slot. Sets FE_PORT, API_PORT, DEBUG_PORT.
81
+ har_allocate_slot_app_ports() {
82
+ local agent_id="$1"
83
+ local step fe_default api_default debug_default
84
+ step="$(har_port_step)"
85
+ fe_default="$(har_default_app_port "${HARNESS_FE_BASE_PORT}" "$agent_id")"
86
+ api_default="$(har_default_app_port "${HARNESS_API_BASE_PORT}" "$agent_id")"
87
+ debug_default=$(( 9200 + agent_id * step ))
88
+
89
+ FE_PORT="$(har_allocate_port "$fe_default" "$fe_default" "$(har_slot_port_lane_end "$fe_default")")"
90
+ API_PORT="$(har_allocate_port "$api_default" "$api_default" "$(har_slot_port_lane_end "$api_default")")"
91
+ DEBUG_PORT="$(har_allocate_port "$debug_default" "$debug_default" $(( debug_default + step - 1 )) )"
92
+ export FE_PORT API_PORT DEBUG_PORT
93
+ }
94
+
95
+ # ── Launch preflight (#36) ─────────────────────────────────────────────────────
96
+
97
+ har_harness_uses_pm2() {
98
+ [ -f "${SCRIPT_DIR}/ecosystem.agent.template.cjs" ]
99
+ }
100
+
101
+ har_port_docker_occupant() {
102
+ local port="$1"
103
+ docker ps --format '{{.Names}}\t{{.Ports}}' 2>/dev/null \
104
+ | grep -E ":${port}->|:${port}/" | head -1 | cut -f1 || true
105
+ }
106
+
107
+ har_check_control_port_conflict() {
108
+ local port="$1"
109
+ local name
110
+ name="$(docker ps --format '{{.Names}}\t{{.Ports}}' 2>/dev/null \
111
+ | grep -i control | grep -E ":${port}->|:${port}/" | head -1 | cut -f1 || true)"
112
+ if [ -n "$name" ]; then
113
+ echo "ERROR: Mission Control container \"${name}\" occupies port ${port}." >&2
114
+ echo " Run: har control down — or use a different agent slot." >&2
115
+ return 1
116
+ fi
117
+ return 0
118
+ }
119
+
120
+ har_check_foreign_pm2() {
121
+ local agent_id="$1"
122
+ local pm2_raw
123
+ har_harness_uses_pm2 || return 0
124
+ pm2_raw="$(npx --yes pm2 jlist 2>/dev/null || true)"
125
+ [ -n "$pm2_raw" ] || return 0
126
+ set +e
127
+ echo "$pm2_raw" | node -e "
128
+ const agentId = process.argv[1];
129
+ const project = process.argv[2];
130
+ const slotPrefix = 'har-' + project + '-agent-' + agentId + '-';
131
+ const legacyPrefix = 'agent-' + agentId + '-';
132
+ let raw = '';
133
+ process.stdin.on('data', c => raw += c);
134
+ process.stdin.on('end', () => {
135
+ try {
136
+ const arr = JSON.parse(raw);
137
+ if (!Array.isArray(arr)) process.exit(0);
138
+ const foreign = arr.filter(x =>
139
+ x.name && (
140
+ (x.name.startsWith('har-') && x.name.includes('-agent-' + agentId + '-') && !x.name.startsWith(slotPrefix)) ||
141
+ (x.name.startsWith(legacyPrefix) && !x.name.startsWith('har-'))
142
+ ));
143
+ if (foreign.length === 0) process.exit(0);
144
+ console.error('ERROR: foreign PM2 processes match agent ' + agentId + ':');
145
+ foreign.forEach(p => {
146
+ const cwd = p.pm2_env?.pm_cwd || p.pm2_env?.cwd || 'unknown';
147
+ console.error(' ' + p.name + ' cwd=' + cwd);
148
+ });
149
+ console.error(' Stop the other harness session or use a different slot.');
150
+ process.exit(1);
151
+ } catch {
152
+ process.exit(0);
153
+ }
154
+ });
155
+ " "$agent_id" "$HARNESS_PROJECT_NAME"
156
+ local rc=$?
157
+ set -e
158
+ return "$rc"
159
+ }
160
+
161
+ # ── Resume failed launch (#38) ───────────────────────────────────────────────
162
+
163
+ slot_session_status() {
164
+ local agent_id="$1"
165
+ read_slot_field "$(slot_registry_file "$agent_id")" status || true
166
+ }
167
+
168
+ slot_is_resumable() {
169
+ local agent_id="$1"
170
+ local status
171
+ status="$(slot_session_status "$agent_id")"
172
+ [ "$status" = "failed" ] || [ "$status" = "starting" ]
173
+ }
174
+
175
+ har_resume_session_assignments() {
176
+ local agent_id="$1"
177
+ local reg work_dir worktree branch suffix base_branch base_commit purpose mode env_file
178
+
179
+ if ! slot_is_resumable "$agent_id"; then
180
+ local status
181
+ status="$(slot_session_status "$agent_id")"
182
+ echo "echo \"ERROR: slot ${agent_id} is not resumable (status=${status:-none}; need failed or starting).\" >&2" >&2
183
+ echo "echo \" Use a normal launch, or --replace to start a fresh session.\" >&2"
184
+ echo 'exit 2'
185
+ return 1
186
+ fi
187
+
188
+ reg="$(slot_registry_file "$agent_id")"
189
+ work_dir="$(read_slot_field "$reg" workDir || true)"
190
+ worktree="$(read_slot_field "$reg" worktreePath || true)"
191
+ branch="$(read_slot_field "$reg" branch || true)"
192
+ suffix="$(read_slot_field "$reg" suffix || true)"
193
+ base_branch="$(read_slot_field "$reg" baseBranch || true)"
194
+ base_commit="$(read_slot_field "$reg" baseCommit || true)"
195
+ purpose="$(read_slot_field "$reg" purpose || true)"
196
+ mode="$(read_slot_field "$reg" mode || true)"
197
+
198
+ if [ -z "$work_dir" ] || [ ! -d "$work_dir" ]; then
199
+ echo "echo \"ERROR: resume requires work dir from registry (missing or not found).\" >&2"
200
+ echo 'exit 1'
201
+ return 1
202
+ fi
203
+
204
+ env_file="$work_dir/.env.agent.${agent_id}"
205
+ if [ ! -f "$env_file" ]; then
206
+ echo "echo \"ERROR: resume requires env file: ${env_file}\" >&2"
207
+ echo 'exit 1'
208
+ return 1
209
+ fi
210
+
211
+ printf '%s\n' \
212
+ "WORK_DIR='${work_dir}'" \
213
+ "WORKTREE_DIR='${worktree}'" \
214
+ "BRANCH='${branch}'" \
215
+ "SUFFIX='${suffix}'" \
216
+ "BASE_BRANCH='${base_branch}'" \
217
+ "BASE_COMMIT='${base_commit}'" \
218
+ "PURPOSE='${purpose}'" \
219
+ "USE_WORKTREE=$([ "$mode" = worktree ] && echo true || echo false)" \
220
+ "ENV_FILE='${env_file}'"
221
+ }
222
+
223
+ har_toolchain_ready() {
224
+ local work_dir="$1"
225
+ if [ -f "$work_dir/package.json" ] && [ -d "$work_dir/node_modules" ]; then
226
+ return 0
227
+ fi
228
+ if [ -d "$work_dir/.har/venv" ]; then
229
+ return 0
230
+ fi
231
+ return 1
232
+ }
233
+
234
+ har_regenerate_agent_env_file() {
235
+ local agent_id="$1"
236
+ local work_dir="$2"
237
+ local env_file="$3"
238
+ local worktree_dir="${4:-}"
239
+ local template="${SCRIPT_DIR}/env.template"
240
+ if [ -f "$template" ]; then
241
+ AGENT_ID="$agent_id" \
242
+ API_PORT="${API_PORT:-}" \
243
+ FE_PORT="${FE_PORT:-}" \
244
+ DEBUG_PORT="${DEBUG_PORT:-}" \
245
+ DB_PORT="${DB_PORT:-${AGENT_DB_PORT:-${HARNESS_DB_PORT_DEFAULT:-15432}}}" \
246
+ MINIO_PORT="${MINIO_PORT:-${AGENT_MINIO_PORT:-${HARNESS_MINIO_PORT_DEFAULT:-19000}}}" \
247
+ BROWSER_PORT="${BROWSER_PORT:-${AGENT_BROWSER_PORT:-${HARNESS_BROWSER_PORT_DEFAULT:-13001}}}" \
248
+ REPO_ROOT="$work_dir" \
249
+ envsubst '${AGENT_ID} ${API_PORT} ${FE_PORT} ${DEBUG_PORT} ${DB_PORT} ${MINIO_PORT} ${BROWSER_PORT} ${REPO_ROOT}' \
250
+ < "$template" > "$env_file"
251
+ return 0
252
+ fi
253
+ cat > "$env_file" <<EOF
254
+ # Agent environment — generated by launch.sh
255
+ AGENT_ID=${agent_id}
256
+ REPO_ROOT=${work_dir}
257
+ WORKTREE_DIR=${worktree_dir}
258
+ NODE_ENV=test
259
+ EOF
260
+ }
261
+
262
+ har_launch_preflight() {
263
+ local agent_id="$1"
264
+ local force="${2:-false}"
265
+ local replace="${3:-false}"
266
+ local resume="${4:-false}"
267
+
268
+ if [ "$resume" = true ]; then
269
+ if ! slot_is_resumable "$agent_id"; then
270
+ local status
271
+ status="$(slot_session_status "$agent_id")"
272
+ echo "ERROR: slot ${agent_id} is not resumable (status=${status:-none}; need failed or starting)." >&2
273
+ echo " Use a normal launch, or --replace to start a fresh session." >&2
274
+ return 2
275
+ fi
276
+ echo "==> [agent-${agent_id}] Resuming partial launch (worktree and deps preserved)..." >&2
277
+ elif slot_is_occupied "$agent_id"; then
278
+ if [ "$replace" != true ] && [ "${HAR_CONFIRM_REPLACE:-}" != "1" ]; then
279
+ print_slot_replace_warning "$agent_id"
280
+ echo "ERROR: slot ${agent_id} is occupied — pass --replace to proceed." >&2
281
+ return 2
282
+ fi
283
+ local wt
284
+ wt="$(existing_slot_worktree "$agent_id")"
285
+ if [ -n "$wt" ] && slot_worktree_dirty "$wt" && [ "$force" != true ]; then
286
+ echo "ERROR: dirty worktree requires --force after explicit user approval." >&2
287
+ return 2
288
+ fi
289
+ fi
290
+
291
+ if har_harness_uses_pm2; then
292
+ har_check_foreign_pm2 "$agent_id" || return 1
293
+ har_allocate_slot_app_ports "$agent_id" || return 1
294
+ local port occupant
295
+ for port in $(printf '%s\n' "$FE_PORT" "$API_PORT" | sort -u); do
296
+ har_check_control_port_conflict "$port" || return 1
297
+ occupant="$(har_port_docker_occupant "$port")"
298
+ if [ -n "$occupant" ] && [[ "$occupant" != har-${HARNESS_PROJECT_NAME}-* ]]; then
299
+ echo "ERROR: Docker container \"${occupant}\" binds port ${port}." >&2
300
+ echo " Stop it with: docker stop ${occupant}" >&2
301
+ return 1
302
+ fi
303
+ done
304
+ fi
305
+ return 0
306
+ }
307
+
308
+ # Load persisted app/infra ports from .env.agent.<id> or the slot registry.
309
+ load_agent_ports() {
310
+ local agent_id="$1"
311
+ local repo_root="$2"
312
+ local env_file reg ports_json
313
+ env_file="$(resolve_agent_env_file "$agent_id" "$repo_root" 2>/dev/null || true)"
314
+ if [ -n "$env_file" ] && [ -f "$env_file" ]; then
315
+ # shellcheck source=/dev/null
316
+ source "$env_file"
317
+ FE_PORT="${FE_PORT:-}"
318
+ API_PORT="${API_PORT:-${PORT:-}}"
319
+ DEBUG_PORT="${DEBUG_PORT:-}"
320
+ DB_PORT="${PGPORT:-${DB_PORT:-}}"
321
+ return 0
322
+ fi
323
+ reg="$(slot_registry_file "$agent_id")"
324
+ if [ -f "$reg" ]; then
325
+ ports_json="$(node -e '
326
+ try {
327
+ const p = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).ports;
328
+ if (p) process.stdout.write(JSON.stringify(p));
329
+ } catch {}
330
+ ' "$reg" 2>/dev/null || true)"
331
+ if [ -n "$ports_json" ]; then
332
+ FE_PORT="$(node -e "const p=JSON.parse(process.argv[1]);process.stdout.write(String(p.frontend??''))" "$ports_json")"
333
+ API_PORT="$(node -e "const p=JSON.parse(process.argv[1]);process.stdout.write(String(p.api??''))" "$ports_json")"
334
+ DEBUG_PORT="$(node -e "const p=JSON.parse(process.argv[1]);process.stdout.write(String(p.debug??''))" "$ports_json")"
335
+ DB_PORT="$(node -e "const p=JSON.parse(process.argv[1]);process.stdout.write(String(p.db??''))" "$ports_json")"
336
+ export FE_PORT API_PORT DEBUG_PORT DB_PORT
337
+ return 0
338
+ fi
339
+ fi
340
+ har_allocate_slot_app_ports "$agent_id"
341
+ DB_PORT="${AGENT_DB_PORT:-${HARNESS_DB_PORT_DEFAULT:-15432}}"
342
+ export FE_PORT API_PORT DEBUG_PORT DB_PORT
343
+ }
344
+
345
+ # ── Project-scoped PM2 names ────────────────────────────────────────────────────
346
+ # Pattern: har-<project>-agent-<id>-<service> (machine-global PM2 namespace).
347
+
348
+ har_pm2_slot_prefix() {
349
+ echo "har-${HARNESS_PROJECT_NAME}-agent-${1}"
350
+ }
351
+
352
+ har_pm2_delete_regex() {
353
+ echo "/^har-${HARNESS_PROJECT_NAME}-agent-${1}-/"
354
+ }
355
+
356
+ har_tmux_session() {
357
+ echo "har-${HARNESS_PROJECT_NAME}-agent-${1}"
358
+ }
359
+
29
360
  # ── Slot registry ─────────────────────────────────────────────────────────────
30
361
  # .har/slots/agent-<id>.json is the source of truth for where a session lives
31
362
  # (worktree path, work dir, branch, base commit). Written by launch.sh, removed
@@ -4,10 +4,17 @@
4
4
  export HARNESS_PROJECT_NAME=__PROJECT_NAME__
5
5
  export HARNESS_USE_WORKTREE=true
6
6
 
7
+ # Optional per-slot HTTP ports for integration tests (unused when no local server).
7
8
  export HARNESS_FE_BASE_PORT=3000
8
9
  export HARNESS_API_BASE_PORT=8000
10
+ export HARNESS_PORT_STEP=10
9
11
  export HARNESS_HEALTH_CHECK_PATH=
10
12
 
13
+ # Shared infra host ports — setup-infra.sh tries DEFAULT first, scans when busy.
14
+ export HARNESS_DB_PORT_DEFAULT=15432
15
+ export HARNESS_DB_PORT_SCAN_START=15432
16
+ export HARNESS_DB_PORT_SCAN_END=15499
17
+
11
18
  export HARNESS_AGENT_SLOT_MIN=1
12
19
  export HARNESS_AGENT_SLOT_MAX=3
13
20
 
@@ -3,7 +3,7 @@
3
3
  # Every launch starts a FRESH session: any previous session for the slot is torn
4
4
  # down (its branch is kept) and a new suffixed worktree is created from HEAD.
5
5
  #
6
- # Usage: ./.har/launch.sh <agent-id> [--no-worktree] [--replace] [--force]
6
+ # Usage: ./.har/launch.sh <agent-id> [--no-worktree] [--replace] [--force] [--resume]
7
7
  set -euo pipefail
8
8
 
9
9
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -18,6 +18,7 @@ AGENT_ID="${1:-}"
18
18
  USE_WORKTREE="${HARNESS_USE_WORKTREE:-true}"
19
19
  FORCE=false
20
20
  REPLACE=false
21
+ RESUME=false
21
22
  PURPOSE="${HAR_SESSION_PURPOSE:-}"
22
23
 
23
24
  for arg in "$@"; do
@@ -26,12 +27,13 @@ for arg in "$@"; do
26
27
  --worktree) USE_WORKTREE=true ;;
27
28
  --replace) REPLACE=true ;;
28
29
  --force) FORCE=true ;;
30
+ --resume) RESUME=true ;;
29
31
  --purpose=*) PURPOSE="${arg#--purpose=}" ;;
30
32
  esac
31
33
  done
32
34
 
33
35
  if [[ -z "$AGENT_ID" ]]; then
34
- echo "Usage: $0 <agent-id> [--no-worktree] [--replace] [--force] [--purpose=label]" >&2
36
+ echo "Usage: $0 <agent-id> [--no-worktree] [--replace] [--force] [--resume] [--purpose=label]" >&2
35
37
  echo " agent-id must be between ${HARNESS_AGENT_SLOT_MIN} and ${HARNESS_AGENT_SLOT_MAX}" >&2
36
38
  exit 1
37
39
  fi
@@ -40,110 +42,158 @@ validate_agent_id "$AGENT_ID"
40
42
 
41
43
  log() { echo "==> [agent-$AGENT_ID] $*" >&2; }
42
44
 
43
- # Replace any previous session for this slot — requires explicit confirmation.
44
- if slot_is_occupied "$AGENT_ID"; then
45
- require_slot_replace_confirm "$AGENT_ID" "$FORCE" "$REPLACE"
46
- log "Replacing previous session for slot ${AGENT_ID}..."
47
- "$SCRIPT_DIR/teardown.sh" "$AGENT_ID" >&2
48
- fi
49
-
50
- "$SCRIPT_DIR/setup-infra.sh"
51
-
52
- # Session worktree (default — use --no-worktree to work in repo root).
53
- # Name encodes what the session is based on: <base-branch>-<sha4>-har-agent-<id>-<rand4>.
54
45
  WORK_DIR="$REPO_ROOT"
55
46
  WORKTREE_DIR=""
56
47
  BRANCH=""
57
48
  SUFFIX=""
58
- BASE_BRANCH="$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "detached")"
59
- BASE_COMMIT="$(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null || true)"
60
- if [ "$USE_WORKTREE" = true ]; then
61
- SHORT_SHA="$(git -C "$REPO_ROOT" rev-parse --short=4 HEAD)"
62
- SUFFIX="$(LC_ALL=C tr -dc 'a-z0-9' </dev/urandom 2>/dev/null | head -c 4 || true)"
63
- [ -n "$SUFFIX" ] || SUFFIX="$(printf '%04d' $(( RANDOM % 10000 )))"
64
- SESSION_NAME="${BASE_BRANCH//\//-}-${SHORT_SHA}-har-agent-${AGENT_ID}-${SUFFIX}"
65
- BRANCH="$SESSION_NAME"
66
- WORKTREE_DIR="$HOME/worktrees/${SESSION_NAME}"
67
- log "Creating session worktree at $WORKTREE_DIR (branch $BRANCH)..."
68
- git -C "$REPO_ROOT" worktree add "$WORKTREE_DIR" -b "$BRANCH"
69
- # Worktrees are always rooted at the git repo if this project lives in a
70
- # subdirectory (monorepo), point WORK_DIR at the project inside the worktree.
71
- REL_PREFIX="$(git -C "$REPO_ROOT" rev-parse --show-prefix 2>/dev/null || true)"
72
- WORK_DIR="${WORKTREE_DIR%/}/${REL_PREFIX}"
73
- WORK_DIR="${WORK_DIR%/}"
49
+ BASE_BRANCH=""
50
+ BASE_COMMIT=""
51
+ ENV_FILE=""
52
+ REGISTRY_WRITTEN=false
53
+
54
+ if [ "$RESUME" = true ]; then
55
+ har_launch_preflight "$AGENT_ID" "$FORCE" "$REPLACE" true || exit $?
56
+ eval "$(har_resume_session_assignments "$AGENT_ID")"
57
+ REGISTRY_WRITTEN=true
58
+ mark_slot_failed() {
59
+ local exit_code="$?"
60
+ if [ "$exit_code" != "0" ] && [ "$REGISTRY_WRITTEN" = true ]; then
61
+ log "Resume failed. Recording failed slot state..."
62
+ set +e
63
+ SLOT_AGENT_ID="$AGENT_ID" \
64
+ SLOT_MODE="$([ "$USE_WORKTREE" = true ] && echo worktree || echo root)" \
65
+ SLOT_WORK_DIR="$WORK_DIR" \
66
+ SLOT_SUFFIX="${SUFFIX:-}" \
67
+ SLOT_WORKTREE_PATH="${WORKTREE_DIR:-}" \
68
+ SLOT_BRANCH="${BRANCH:-}" \
69
+ SLOT_BASE_BRANCH="${BASE_BRANCH:-}" \
70
+ SLOT_BASE_COMMIT="${BASE_COMMIT:-}" \
71
+ SLOT_PURPOSE="${PURPOSE}" \
72
+ SLOT_STATUS="failed" \
73
+ SLOT_LAST_ERROR="launch.sh --resume exited with code ${exit_code}" \
74
+ write_slot_registry
75
+ log " Work dir: ${WORK_DIR}"
76
+ log " Env file: ${ENV_FILE}"
77
+ log " Recovery: ./.har/launch.sh ${AGENT_ID} --resume"
78
+ fi
79
+ }
80
+ trap mark_slot_failed EXIT
74
81
  else
75
- log "Using repo root (worktree disabled)"
76
- fi
82
+ har_launch_preflight "$AGENT_ID" "$FORCE" "$REPLACE" || exit $?
77
83
 
78
- # Keep harness-generated files out of accidental `git add -A` commits in repos
79
- # whose .gitignore doesn't know about har (applies to all worktrees too).
80
- GIT_EXCLUDE="$(git -C "$REPO_ROOT" rev-parse --git-common-dir 2>/dev/null)/info/exclude"
81
- if [ -n "$GIT_EXCLUDE" ] && [ -d "$(dirname "$GIT_EXCLUDE")" ]; then
82
- for pattern in '.env.agent.*' 'ecosystem.agent.*.config.cjs' '.har/venv'; do
83
- grep -qxF "$pattern" "$GIT_EXCLUDE" 2>/dev/null || echo "$pattern" >> "$GIT_EXCLUDE"
84
- done
84
+ if slot_is_occupied "$AGENT_ID"; then
85
+ require_slot_replace_confirm "$AGENT_ID" "$FORCE" "$REPLACE"
86
+ log "Replacing previous session for slot ${AGENT_ID}..."
87
+ "$SCRIPT_DIR/teardown.sh" "$AGENT_ID" >&2
88
+ fi
85
89
  fi
86
90
 
87
- ENV_FILE="$WORK_DIR/.env.agent.${AGENT_ID}"
88
- log "Generating $ENV_FILE..."
89
- cat > "$ENV_FILE" <<EOF
90
- # Agent environment — generated by launch.sh
91
- AGENT_ID=${AGENT_ID}
92
- REPO_ROOT=${WORK_DIR}
93
- WORKTREE_DIR=${WORKTREE_DIR}
94
- NODE_ENV=test
95
- EOF
91
+ "$SCRIPT_DIR/setup-infra.sh"
96
92
 
97
- REGISTRY_WRITTEN=false
98
- mark_slot_failed() {
99
- local exit_code="$?"
100
- if [ "$exit_code" != "0" ] && [ "$REGISTRY_WRITTEN" = true ]; then
101
- log "Launch failed after creating the session. Recording failed slot state..."
102
- set +e
103
- SLOT_AGENT_ID="$AGENT_ID" \
104
- SLOT_MODE="$([ "$USE_WORKTREE" = true ] && echo worktree || echo root)" \
105
- SLOT_WORK_DIR="$WORK_DIR" \
106
- SLOT_SUFFIX="${SUFFIX:-}" \
107
- SLOT_WORKTREE_PATH="${WORKTREE_DIR:-}" \
108
- SLOT_BRANCH="${BRANCH:-}" \
109
- SLOT_BASE_BRANCH="${BASE_BRANCH:-}" \
110
- SLOT_BASE_COMMIT="${BASE_COMMIT:-}" \
111
- SLOT_PURPOSE="${PURPOSE}" \
112
- SLOT_STATUS="failed" \
113
- SLOT_LAST_ERROR="launch.sh exited with code ${exit_code}" \
114
- write_slot_registry
115
- log " Work dir: ${WORK_DIR}"
116
- log " Env file: ${ENV_FILE}"
117
- log " Recovery: fix the failure, then relaunch with --replace when ready."
93
+ if [ "$RESUME" != true ]; then
94
+ WORK_DIR="$REPO_ROOT"
95
+ WORKTREE_DIR=""
96
+ BRANCH=""
97
+ SUFFIX=""
98
+ BASE_BRANCH="$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "detached")"
99
+ BASE_COMMIT="$(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null || true)"
100
+ if [ "$USE_WORKTREE" = true ]; then
101
+ SHORT_SHA="$(git -C "$REPO_ROOT" rev-parse --short=4 HEAD)"
102
+ SUFFIX="$(LC_ALL=C tr -dc 'a-z0-9' </dev/urandom 2>/dev/null | head -c 4 || true)"
103
+ [ -n "$SUFFIX" ] || SUFFIX="$(printf '%04d' $(( RANDOM % 10000 )))"
104
+ SESSION_NAME="${BASE_BRANCH//\//-}-${SHORT_SHA}-har-agent-${AGENT_ID}-${SUFFIX}"
105
+ BRANCH="$SESSION_NAME"
106
+ WORKTREE_DIR="$HOME/worktrees/${SESSION_NAME}"
107
+ log "Creating session worktree at $WORKTREE_DIR (branch $BRANCH)..."
108
+ git -C "$REPO_ROOT" worktree add "$WORKTREE_DIR" -b "$BRANCH"
109
+ REL_PREFIX="$(git -C "$REPO_ROOT" rev-parse --show-prefix 2>/dev/null || true)"
110
+ WORK_DIR="${WORKTREE_DIR%/}/${REL_PREFIX}"
111
+ WORK_DIR="${WORK_DIR%/}"
112
+ else
113
+ log "Using repo root (worktree disabled)"
114
+ REL_PREFIX="$(git -C "$REPO_ROOT" rev-parse --show-prefix 2>/dev/null || true)"
118
115
  fi
119
- }
120
- trap mark_slot_failed EXIT
121
116
 
122
- # Record the session before slow or fragile setup so verify/status/teardown can
123
- # recover partial launches that already created a worktree and env file.
124
- SLOT_AGENT_ID="$AGENT_ID" \
125
- SLOT_MODE="$([ "$USE_WORKTREE" = true ] && echo worktree || echo root)" \
126
- SLOT_WORK_DIR="$WORK_DIR" \
127
- SLOT_SUFFIX="${SUFFIX:-}" \
128
- SLOT_WORKTREE_PATH="${WORKTREE_DIR:-}" \
129
- SLOT_BRANCH="${BRANCH:-}" \
130
- SLOT_BASE_BRANCH="${BASE_BRANCH:-}" \
131
- SLOT_BASE_COMMIT="${BASE_COMMIT:-}" \
132
- SLOT_PURPOSE="${PURPOSE}" \
133
- SLOT_STATUS="starting" \
134
- write_slot_registry
135
- REGISTRY_WRITTEN=true
136
-
137
- log "Provisioning toolchain (see harness.env: HARNESS_ECOSYSTEM, HARNESS_INSTALL_CMD)..."
138
- HAR_WORK_DIR="$WORK_DIR" \
139
- HAR_ENV_FILE="$ENV_FILE" \
140
- HAR_WORKTREE_DIR="${WORKTREE_DIR:-}" \
141
- HAR_REL_PREFIX="${REL_PREFIX:-}" \
142
- HAR_AGENT_ID="$AGENT_ID" \
143
- "$SCRIPT_DIR/provision-toolchain.sh"
144
-
145
- # Record the session in the slot registry — the source of truth for where
146
- # this slot's code lives (status/verify/teardown resolve through it).
117
+ GIT_EXCLUDE="$(git -C "$REPO_ROOT" rev-parse --git-common-dir 2>/dev/null)/info/exclude"
118
+ if [ -n "$GIT_EXCLUDE" ] && [ -d "$(dirname "$GIT_EXCLUDE")" ]; then
119
+ for pattern in '.env.agent.*' 'ecosystem.agent.*.config.cjs' '.har/venv'; do
120
+ grep -qxF "$pattern" "$GIT_EXCLUDE" 2>/dev/null || echo "$pattern" >> "$GIT_EXCLUDE"
121
+ done
122
+ fi
123
+
124
+ ENV_FILE="$WORK_DIR/.env.agent.${AGENT_ID}"
125
+ log "Generating $ENV_FILE..."
126
+ har_regenerate_agent_env_file "$AGENT_ID" "$WORK_DIR" "$ENV_FILE" "${WORKTREE_DIR:-}"
127
+
128
+ REGISTRY_WRITTEN=false
129
+ mark_slot_failed() {
130
+ local exit_code="$?"
131
+ if [ "$exit_code" != "0" ] && [ "$REGISTRY_WRITTEN" = true ]; then
132
+ log "Launch failed after creating the session. Recording failed slot state..."
133
+ set +e
134
+ SLOT_AGENT_ID="$AGENT_ID" \
135
+ SLOT_MODE="$([ "$USE_WORKTREE" = true ] && echo worktree || echo root)" \
136
+ SLOT_WORK_DIR="$WORK_DIR" \
137
+ SLOT_SUFFIX="${SUFFIX:-}" \
138
+ SLOT_WORKTREE_PATH="${WORKTREE_DIR:-}" \
139
+ SLOT_BRANCH="${BRANCH:-}" \
140
+ SLOT_BASE_BRANCH="${BASE_BRANCH:-}" \
141
+ SLOT_BASE_COMMIT="${BASE_COMMIT:-}" \
142
+ SLOT_PURPOSE="${PURPOSE}" \
143
+ SLOT_STATUS="failed" \
144
+ SLOT_LAST_ERROR="launch.sh exited with code ${exit_code}" \
145
+ write_slot_registry
146
+ log " Work dir: ${WORK_DIR}"
147
+ log " Env file: ${ENV_FILE}"
148
+ log " Recovery: ./.har/launch.sh ${AGENT_ID} --resume"
149
+ fi
150
+ }
151
+ trap mark_slot_failed EXIT
152
+
153
+ SLOT_AGENT_ID="$AGENT_ID" \
154
+ SLOT_MODE="$([ "$USE_WORKTREE" = true ] && echo worktree || echo root)" \
155
+ SLOT_WORK_DIR="$WORK_DIR" \
156
+ SLOT_SUFFIX="${SUFFIX:-}" \
157
+ SLOT_WORKTREE_PATH="${WORKTREE_DIR:-}" \
158
+ SLOT_BRANCH="${BRANCH:-}" \
159
+ SLOT_BASE_BRANCH="${BASE_BRANCH:-}" \
160
+ SLOT_BASE_COMMIT="${BASE_COMMIT:-}" \
161
+ SLOT_PURPOSE="${PURPOSE}" \
162
+ SLOT_STATUS="starting" \
163
+ write_slot_registry
164
+ REGISTRY_WRITTEN=true
165
+ else
166
+ REL_PREFIX="$(git -C "$REPO_ROOT" rev-parse --show-prefix 2>/dev/null || true)"
167
+ log "Resuming session at ${WORK_DIR}"
168
+ har_regenerate_agent_env_file "$AGENT_ID" "$WORK_DIR" "$ENV_FILE" "${WORKTREE_DIR:-}"
169
+ SLOT_AGENT_ID="$AGENT_ID" \
170
+ SLOT_MODE="$([ "$USE_WORKTREE" = true ] && echo worktree || echo root)" \
171
+ SLOT_WORK_DIR="$WORK_DIR" \
172
+ SLOT_SUFFIX="${SUFFIX:-}" \
173
+ SLOT_WORKTREE_PATH="${WORKTREE_DIR:-}" \
174
+ SLOT_BRANCH="${BRANCH:-}" \
175
+ SLOT_BASE_BRANCH="${BASE_BRANCH:-}" \
176
+ SLOT_BASE_COMMIT="${BASE_COMMIT:-}" \
177
+ SLOT_PURPOSE="${PURPOSE}" \
178
+ SLOT_STATUS="starting" \
179
+ write_slot_registry
180
+ fi
181
+
182
+ if [ "$RESUME" = true ] && har_toolchain_ready "$WORK_DIR"; then
183
+ log "Toolchain already provisioned — skipping install."
184
+ elif [ -f "$SCRIPT_DIR/provision-toolchain.sh" ]; then
185
+ log "Provisioning toolchain (see harness.env: HARNESS_ECOSYSTEM, HARNESS_INSTALL_CMD)..."
186
+ HAR_WORK_DIR="$WORK_DIR" \
187
+ HAR_ENV_FILE="$ENV_FILE" \
188
+ HAR_WORKTREE_DIR="${WORKTREE_DIR:-}" \
189
+ HAR_REL_PREFIX="${REL_PREFIX:-}" \
190
+ HAR_AGENT_ID="$AGENT_ID" \
191
+ "$SCRIPT_DIR/provision-toolchain.sh"
192
+ elif [ -f "$WORK_DIR/package.json" ] && [ ! -d "$WORK_DIR/node_modules" ]; then
193
+ log "Installing dependencies in $WORK_DIR..."
194
+ (cd "$WORK_DIR" && npm install --silent)
195
+ fi
196
+
147
197
  SLOT_AGENT_ID="$AGENT_ID" \
148
198
  SLOT_MODE="$([ "$USE_WORKTREE" = true ] && echo worktree || echo root)" \
149
199
  SLOT_WORK_DIR="$WORK_DIR" \