@@ -115,7 +115,28 @@ tasks:
115115 echo "VM Golden Name: {{.VM_GOLDEN_NAME}}"
116116 echo "UTM VMs Directory: {{.UTM_VMS_DIR}}"
117117 echo 'Force Download: {{ eq .FORCE "true" }}'
118-
118+
119+ # Quit and relaunch UTM so it reloads its VM registry from disk. UTM does
120+ # not live-watch its Documents dir, so VM bundles copied in underneath a
121+ # running UTM stay invisible until it is bounced.
122+ reload_utm() {
123+ echo "Reloading UTM so it picks up VM changes on disk..."
124+ osascript -e 'tell application "UTM" to quit' >/dev/null 2>&1 || true
125+ sleep 3
126+ pkill -f "UTM.app/Contents/MacOS/UTM" >/dev/null 2>&1 || true
127+ sleep 2
128+ open -g -a UTM || true
129+ # Wait until UTM is scriptable again before relying on utmctl.
130+ for _ in $(seq 1 10); do
131+ if osascript -e 'with timeout of 5 seconds' \
132+ -e 'tell application "UTM" to get name of every virtual machine' \
133+ -e 'end timeout' >/dev/null 2>&1; then
134+ break
135+ fi
136+ sleep 2
137+ done
138+ }
139+
119140 if [ "{{.FORCE}}" = "true" ]; then
120141 echo "Force flag is set, re-downloading golden VM image..."
121142 else
@@ -128,9 +149,15 @@ tasks:
128149 # if the .utm file exists in UTM_VMS_DIR, skip download
129150 echo "Checking UTM VMs directory for existing golden VM...: {{.UTM_VMS_DIR}}/{{.VM_GOLDEN_NAME}}.utm"
130151 if [ -d "{{.UTM_VMS_DIR}}/{{.VM_GOLDEN_NAME}}.utm" ] || [ -f "{{.UTM_VMS_DIR}}/{{.VM_GOLDEN_NAME}}.utm" ]; then
131- echo "✓ Golden VM already exists in UTM directory: {{.UTM_VMS_DIR}}/{{.VM_GOLDEN_NAME}}.utm"
132- echo "👉 Please restart UTM to reload VMs ('open -a UTM'), then run 'task vm:start'."
133- exit 1 # non zero would stop further steps
152+ echo "✓ Golden VM exists on disk but UTM hasn't loaded it: {{.UTM_VMS_DIR}}/{{.VM_GOLDEN_NAME}}.utm"
153+ reload_utm
154+ if utmctl status "{{.VM_GOLDEN_NAME}}" >/dev/null 2>&1; then
155+ echo "✓ UTM reloaded and now sees the golden VM."
156+ exit 0
157+ fi
158+ echo "❌ UTM still doesn't see {{.VM_GOLDEN_NAME}} after a reload."
159+ echo " Open UTM and import it manually, then re-run."
160+ exit 1
134161 else
135162 echo "✓ No existing golden VM found, proceeding to download."
136163 fi
@@ -193,10 +220,18 @@ tasks:
193220
194221 # Copy the VM to UTM directory and rename
195222 cp -r "$TEMP_DIR/golden-image.utm" "{{.UTM_VMS_DIR}}/{{.VM_GOLDEN_NAME}}.utm"
196-
197- echo "✓ Golden VM downloaded successfully and placed in UTM directory: {{.UTM_VMS_DIR}}/{{.VM_GOLDEN_NAME}}.utm"
198- echo "👉 Please restart UTM to reload VMs ('open -a UTM'), then run 'task vm:start'."
199- exit 1 # non zero would stop further steps
223+ echo "✓ Golden VM placed in UTM directory: {{.UTM_VMS_DIR}}/{{.VM_GOLDEN_NAME}}.utm"
224+
225+ # We just wrote a VM bundle under a running UTM; reload it so it registers
226+ # the new golden VM, then let the caller (vm:reset) continue.
227+ reload_utm
228+ if utmctl status "{{.VM_GOLDEN_NAME}}" >/dev/null 2>&1; then
229+ echo "✓ UTM reloaded and now sees the golden VM."
230+ exit 0
231+ fi
232+ echo "❌ UTM still doesn't see {{.VM_GOLDEN_NAME}} after a reload."
233+ echo " Open UTM and import it manually, then re-run."
234+ exit 1
200235
201236 vm:reset :
202237 desc : " Clone the golden VM to create a working instance with passwordless SSH"
@@ -231,15 +266,27 @@ tasks:
231266 if utmctl status "{{.VM_NAME}}" >/dev/null 2>&1; then
232267 echo "Stopping and removing existing working VM..."
233268 utmctl stop "{{.VM_NAME}}" 2>/dev/null || true
234- sleep 2
235269 utmctl delete "{{.VM_NAME}}" 2>/dev/null || true
236- sleep 1
270+ # Wait until UTM drops the VM from its registry (status goes non-zero =
271+ # not found) so the clone below doesn't race a half-deleted entry.
272+ for _ in $(seq 1 10); do
273+ utmctl status "{{.VM_NAME}}" >/dev/null 2>&1 || break
274+ sleep 1
275+ done
237276 fi
238-
277+
239278 # Clone the golden VM
240279 echo "Cloning '{{.VM_GOLDEN_NAME}}' to '{{.VM_NAME}}'..."
241280 utmctl clone "{{.VM_GOLDEN_NAME}}" --name "{{.VM_NAME}}"
242-
281+
282+ # Wait until UTM has registered the clone as a stopped VM before starting it.
283+ # Starting immediately after a clone can wedge UTM's Apple Event handler.
284+ echo "Waiting for UTM to register the cloned VM..."
285+ for _ in $(seq 1 15); do
286+ [ "$(utmctl status "{{.VM_NAME}}" 2>/dev/null)" = "stopped" ] && break
287+ sleep 1
288+ done
289+
243290 echo "✓ VM cloned. Now starting it for initial configuration..."
244291 - task : vm:start
245292 - task : vm:configure:ssh
@@ -265,19 +312,27 @@ tasks:
265312 deps :
266313 - vm:ensure-exists
267314 cmds :
268- - utmctl start --hide "{{.VM_NAME}}"
269315 - |
270- echo "Waiting for VM to boot (checking every 5 seconds)..."
271-
316+ echo "Starting VM and waiting for it to boot (checking every 5 seconds)..."
317+
272318 # Poll for VM to be ready with timeout
273319 MAX_ATTEMPTS=24 # 24 * 5 seconds = 2 minutes max wait
274320 ATTEMPT=0
321+ # utmctl start can exit 0 yet not launch the VM, so we verify run state
322+ # and re-issue the start while still stopped, up to this many times.
323+ MAX_START_ATTEMPTS=3
324+ START_ATTEMPTS=0
325+
326+ utmctl start --hide "{{.VM_NAME}}" 2>&1 || true
327+ START_ATTEMPTS=1
275328
276329 while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
277330 ATTEMPT=$((ATTEMPT + 1))
278331
279- # Check if VM is running
280- if utmctl status "{{.VM_NAME}}" >/dev/null 2>&1; then
332+ # Gate on the reported state: utmctl status exits 0 even when stopped
333+ # (only "not found" is non-zero), so the exit code can't gate this.
334+ STATE=$(utmctl status "{{.VM_NAME}}" 2>/dev/null)
335+ if [ "$STATE" = "started" ]; then
281336 echo "✓ VM is running, checking if network is ready..."
282337
283338 VM_IP=$({{.GET_VM_IP}} get_vm_ip)
@@ -287,8 +342,41 @@ tasks:
287342 else
288343 echo "VM is running but network not ready yet (attempt $ATTEMPT/$MAX_ATTEMPTS)..."
289344 fi
345+ elif [ "$STATE" = "stopped" ]; then
346+ # Start didn't take. A plain retry clears a transient glitch; the final
347+ # attempt relaunches UTM.app, which clears a wedged start path.
348+ if [ $START_ATTEMPTS -lt $MAX_START_ATTEMPTS ]; then
349+ START_ATTEMPTS=$((START_ATTEMPTS + 1))
350+ if [ $START_ATTEMPTS -eq $MAX_START_ATTEMPTS ]; then
351+ # Bounce UTM.app; safe since the target VM is stopped.
352+ echo "VM still stopped; relaunching UTM.app to clear a stuck start path..."
353+ osascript -e 'tell application "UTM" to quit' >/dev/null 2>&1 || true
354+ sleep 3
355+ pkill -f "UTM.app/Contents/MacOS/UTM" >/dev/null 2>&1 || true
356+ sleep 2
357+ open -g -a UTM || true
358+ # Wait until UTM is scriptable again before re-issuing the start.
359+ for _ in $(seq 1 10); do
360+ if osascript -e 'with timeout of 5 seconds' \
361+ -e 'tell application "UTM" to get name of every virtual machine' \
362+ -e 'end timeout' >/dev/null 2>&1; then
363+ break
364+ fi
365+ sleep 2
366+ done
367+ else
368+ echo "VM still stopped; re-issuing start (try $START_ATTEMPTS/$MAX_START_ATTEMPTS)..."
369+ fi
370+ utmctl start --hide "{{.VM_NAME}}" 2>&1 || true
371+ else
372+ echo "❌ VM is still 'stopped' after $MAX_START_ATTEMPTS start attempts (including a UTM relaunch)."
373+ echo " Try manually: quit UTM (⌘Q), reopen it, then 'task vm:start'."
374+ echo " If it still fails, open the VM in the UTM GUI and start it there"
375+ echo " to surface any blocking dialog or boot error. Aborting."
376+ exit 1
377+ fi
290378 else
291- echo "VM is starting... ( attempt $ATTEMPT/$MAX_ATTEMPTS)"
379+ echo "VM is starting (state: ${STATE:-unknown}, attempt $ATTEMPT/$MAX_ATTEMPTS)... "
292380 fi
293381
294382 if [ $ATTEMPT -lt $MAX_ATTEMPTS ]; then
0 commit comments