fix: handle Parallels poweroff snapshot restores

This commit is contained in:
Peter Steinberger
2026-03-17 04:01:15 +00:00
parent 71a79bdf5c
commit 095a9f6e1d
4 changed files with 196 additions and 27 deletions

View File

@@ -42,10 +42,13 @@ pnpm test:parallels:macos \
## Notes
- Snapshot target: closest to `macOS 26.3.1 fresh`.
- Snapshot resolver now prefers matching `*-poweroff*` clones when the base hint also matches. That lets the harness reuse disk-only recovery snapshots without passing a longer hint.
- If Windows/Linux snapshot restore logs show `PET_QUESTION_SNAPSHOT_STATE_INCOMPATIBLE_CPU`, drop the suspended state once, create a `*-poweroff*` replacement snapshot, and rerun. The smoke scripts now auto-start restored power-off snapshots.
- Harness configures Discord inside the guest; no checked-in token/config.
- Use the `openclaw` wrapper for guest `message send/read`; `node openclaw.mjs message ...` does not expose the lazy message subcommands the same way.
- Write `channels.discord.guilds` in one JSON object (`--strict-json`), not dotted `config set channels.discord.guilds.<snowflake>...` paths; numeric snowflakes get treated like array indexes.
- Avoid `prlctl enter` / expect for long Discord setup scripts; it line-wraps/corrupts long commands. Use `prlctl exec --current-user /bin/sh -lc ...` for the Discord config phase.
- Full 3-OS sweeps: the shared build lock is safe in parallel, but snapshot restore is still a Parallels bottleneck. Prefer serialized Windows/Linux restore-heavy reruns if the host is already under load.
- Harness cleanup deletes the temporary Discord smoke messages at exit.
- Per-phase logs: `/tmp/openclaw-parallels-smoke.*`
- Machine summary: pass `--json`

View File

@@ -14,6 +14,9 @@ INSTALL_VERSION=""
TARGET_PACKAGE_SPEC=""
JSON_OUTPUT=0
KEEP_SERVER=0
SNAPSHOT_ID=""
SNAPSHOT_STATE=""
SNAPSHOT_NAME=""
MAIN_TGZ_DIR="$(mktemp -d)"
MAIN_TGZ_PATH=""
@@ -163,7 +166,7 @@ esac
OPENAI_API_KEY_VALUE="${!OPENAI_API_KEY_ENV:-}"
[[ -n "$OPENAI_API_KEY_VALUE" ]] || die "$OPENAI_API_KEY_ENV is required"
resolve_snapshot_id() {
resolve_snapshot_info() {
local json hint
json="$(prlctl snapshot-list "$VM_NAME" --json)"
hint="$SNAPSHOT_HINT"
@@ -171,28 +174,54 @@ resolve_snapshot_id() {
import difflib
import json
import os
import re
import sys
payload = json.loads(os.environ["SNAPSHOT_JSON"])
hint = os.environ["SNAPSHOT_HINT"].strip().lower()
best_id = None
best_meta = None
best_score = -1.0
def aliases(name: str) -> list[str]:
values = [name]
for pattern in (
r"^(.*)-poweroff$",
r"^(.*)-poweroff-\d{4}-\d{2}-\d{2}$",
):
match = re.match(pattern, name)
if match:
values.append(match.group(1))
return values
for snapshot_id, meta in payload.items():
name = str(meta.get("name", "")).strip()
lowered = name.lower()
score = 0.0
if lowered == hint:
score = 10.0
elif hint and hint in lowered:
score = 5.0 + len(hint) / max(len(lowered), 1)
else:
score = difflib.SequenceMatcher(None, hint, lowered).ratio()
for alias in aliases(lowered):
if alias == hint:
score = max(score, 10.0)
elif hint and hint in alias:
score = max(score, 5.0 + len(hint) / max(len(alias), 1))
else:
score = max(score, difflib.SequenceMatcher(None, hint, alias).ratio())
if str(meta.get("state", "")).lower() == "poweroff":
score += 0.5
if score > best_score:
best_score = score
best_id = snapshot_id
best_meta = meta
if not best_id:
sys.exit("no snapshot matched")
print(best_id)
print(
"\t".join(
[
best_id,
str(best_meta.get("state", "")).strip(),
str(best_meta.get("name", "")).strip(),
]
)
)
PY
}
@@ -251,10 +280,42 @@ guest_exec() {
prlctl exec "$VM_NAME" "$@"
}
wait_for_vm_status() {
local expected="$1"
local deadline status
deadline=$((SECONDS + TIMEOUT_SNAPSHOT_S))
while (( SECONDS < deadline )); do
status="$(prlctl status "$VM_NAME" 2>/dev/null || true)"
if [[ "$status" == *" $expected" ]]; then
return 0
fi
sleep 1
done
return 1
}
wait_for_guest_ready() {
local deadline
deadline=$((SECONDS + TIMEOUT_SNAPSHOT_S))
while (( SECONDS < deadline )); do
if guest_exec /bin/true >/dev/null 2>&1; then
return 0
fi
sleep 2
done
return 1
}
restore_snapshot() {
local snapshot_id="$1"
say "Restore snapshot $SNAPSHOT_HINT ($snapshot_id)"
prlctl snapshot-switch "$VM_NAME" --id "$snapshot_id" >/dev/null
if [[ "$SNAPSHOT_STATE" == "poweroff" ]]; then
wait_for_vm_status "stopped" || die "restored poweroff snapshot did not reach stopped state in $VM_NAME"
say "Start restored poweroff snapshot $SNAPSHOT_NAME"
prlctl start "$VM_NAME" >/dev/null
fi
wait_for_guest_ready || die "guest did not become ready in $VM_NAME"
}
bootstrap_guest() {
@@ -585,13 +646,16 @@ run_upgrade_lane() {
UPGRADE_AGENT_STATUS="pass"
}
SNAPSHOT_ID="$(resolve_snapshot_id)"
IFS=$'\t' read -r SNAPSHOT_ID SNAPSHOT_STATE SNAPSHOT_NAME <<<"$(resolve_snapshot_info)"
[[ -n "$SNAPSHOT_ID" ]] || die "failed to resolve snapshot id"
[[ -n "$SNAPSHOT_NAME" ]] || SNAPSHOT_NAME="$SNAPSHOT_HINT"
LATEST_VERSION="$(resolve_latest_version)"
HOST_IP="$(resolve_host_ip)"
HOST_PORT="$(resolve_host_port)"
say "VM: $VM_NAME"
say "Snapshot hint: $SNAPSHOT_HINT"
say "Resolved snapshot: $SNAPSHOT_NAME [$SNAPSHOT_STATE]"
say "Latest npm version: $LATEST_VERSION"
say "Current head: $(git rev-parse --short HEAD)"
say "Run logs: $RUN_DIR"

View File

@@ -21,6 +21,9 @@ DISCORD_TOKEN_ENV=""
DISCORD_TOKEN_VALUE=""
DISCORD_GUILD_ID=""
DISCORD_CHANNEL_ID=""
SNAPSHOT_ID=""
SNAPSHOT_STATE=""
SNAPSHOT_NAME=""
GUEST_OPENCLAW_BIN="/opt/homebrew/bin/openclaw"
GUEST_OPENCLAW_ENTRY="/opt/homebrew/lib/node_modules/openclaw/openclaw.mjs"
GUEST_NODE_BIN="/opt/homebrew/bin/node"
@@ -291,7 +294,7 @@ cleanup_discord_smoke_messages() {
discord_delete_message_id_file "$RUN_DIR/upgrade.discord-host-message-id"
}
resolve_snapshot_id() {
resolve_snapshot_info() {
local json hint
json="$(prlctl snapshot-list "$VM_NAME" --json)"
hint="$SNAPSHOT_HINT"
@@ -299,28 +302,54 @@ resolve_snapshot_id() {
import difflib
import json
import os
import re
import sys
payload = json.loads(os.environ["SNAPSHOT_JSON"])
hint = os.environ["SNAPSHOT_HINT"].strip().lower()
best_id = None
best_meta = None
best_score = -1.0
def aliases(name: str) -> list[str]:
values = [name]
for pattern in (
r"^(.*)-poweroff$",
r"^(.*)-poweroff-\d{4}-\d{2}-\d{2}$",
):
match = re.match(pattern, name)
if match:
values.append(match.group(1))
return values
for snapshot_id, meta in payload.items():
name = str(meta.get("name", "")).strip()
lowered = name.lower()
score = 0.0
if lowered == hint:
score = 10.0
elif hint and hint in lowered:
score = 5.0 + len(hint) / max(len(lowered), 1)
else:
score = difflib.SequenceMatcher(None, hint, lowered).ratio()
for alias in aliases(lowered):
if alias == hint:
score = max(score, 10.0)
elif hint and hint in alias:
score = max(score, 5.0 + len(hint) / max(len(alias), 1))
else:
score = max(score, difflib.SequenceMatcher(None, hint, alias).ratio())
if str(meta.get("state", "")).lower() == "poweroff":
score += 0.5
if score > best_score:
best_score = score
best_id = snapshot_id
best_meta = meta
if not best_id:
sys.exit("no snapshot matched")
print(best_id)
print(
"\t".join(
[
best_id,
str(best_meta.get("state", "")).strip(),
str(best_meta.get("name", "")).strip(),
]
)
)
PY
}
@@ -377,6 +406,20 @@ resolve_host_port() {
printf '%s\n' "$HOST_PORT"
}
wait_for_vm_status() {
local expected="$1"
local deadline status
deadline=$((SECONDS + TIMEOUT_SNAPSHOT_S))
while (( SECONDS < deadline )); do
status="$(prlctl status "$VM_NAME" 2>/dev/null || true)"
if [[ "$status" == *" $expected" ]]; then
return 0
fi
sleep 1
done
return 1
}
wait_for_current_user() {
local deadline
deadline=$((SECONDS + TIMEOUT_SNAPSHOT_S))
@@ -458,6 +501,11 @@ restore_snapshot() {
local snapshot_id="$1"
say "Restore snapshot $SNAPSHOT_HINT ($snapshot_id)"
prlctl snapshot-switch "$VM_NAME" --id "$snapshot_id" >/dev/null
if [[ "$SNAPSHOT_STATE" == "poweroff" ]]; then
wait_for_vm_status "stopped" || die "restored poweroff snapshot did not reach stopped state in $VM_NAME"
say "Start restored poweroff snapshot $SNAPSHOT_NAME"
prlctl start "$VM_NAME" >/dev/null
fi
wait_for_current_user || die "desktop user did not become ready in $VM_NAME"
}
@@ -1017,13 +1065,16 @@ FRESH_MAIN_STATUS="skip"
UPGRADE_STATUS="skip"
UPGRADE_PRECHECK_STATUS="skip"
SNAPSHOT_ID="$(resolve_snapshot_id)"
IFS=$'\t' read -r SNAPSHOT_ID SNAPSHOT_STATE SNAPSHOT_NAME <<<"$(resolve_snapshot_info)"
[[ -n "$SNAPSHOT_ID" ]] || die "failed to resolve snapshot id"
[[ -n "$SNAPSHOT_NAME" ]] || SNAPSHOT_NAME="$SNAPSHOT_HINT"
LATEST_VERSION="$(resolve_latest_version)"
HOST_IP="$(resolve_host_ip)"
HOST_PORT="$(resolve_host_port)"
say "VM: $VM_NAME"
say "Snapshot hint: $SNAPSHOT_HINT"
say "Resolved snapshot: $SNAPSHOT_NAME [$SNAPSHOT_STATE]"
say "Latest npm version: $LATEST_VERSION"
say "Current head: $(git rev-parse --short HEAD)"
if discord_smoke_enabled; then

View File

@@ -15,6 +15,9 @@ TARGET_PACKAGE_SPEC=""
JSON_OUTPUT=0
KEEP_SERVER=0
CHECK_LATEST_REF=1
SNAPSHOT_ID=""
SNAPSHOT_STATE=""
SNAPSHOT_NAME=""
MAIN_TGZ_DIR="$(mktemp -d)"
MAIN_TGZ_PATH=""
@@ -194,7 +197,7 @@ ps_array_literal() {
printf '@(%s)' "$joined"
}
resolve_snapshot_id() {
resolve_snapshot_info() {
local json hint
json="$(prlctl snapshot-list "$VM_NAME" --json)"
hint="$SNAPSHOT_HINT"
@@ -202,28 +205,54 @@ resolve_snapshot_id() {
import difflib
import json
import os
import re
import sys
payload = json.loads(os.environ["SNAPSHOT_JSON"])
hint = os.environ["SNAPSHOT_HINT"].strip().lower()
best_id = None
best_meta = None
best_score = -1.0
def aliases(name: str) -> list[str]:
values = [name]
for pattern in (
r"^(.*)-poweroff$",
r"^(.*)-poweroff-\d{4}-\d{2}-\d{2}$",
):
match = re.match(pattern, name)
if match:
values.append(match.group(1))
return values
for snapshot_id, meta in payload.items():
name = str(meta.get("name", "")).strip()
lowered = name.lower()
score = 0.0
if lowered == hint:
score = 10.0
elif hint and hint in lowered:
score = 5.0 + len(hint) / max(len(lowered), 1)
else:
score = difflib.SequenceMatcher(None, hint, lowered).ratio()
for alias in aliases(lowered):
if alias == hint:
score = max(score, 10.0)
elif hint and hint in alias:
score = max(score, 5.0 + len(hint) / max(len(alias), 1))
else:
score = max(score, difflib.SequenceMatcher(None, hint, alias).ratio())
if str(meta.get("state", "")).lower() == "poweroff":
score += 0.5
if score > best_score:
best_score = score
best_id = snapshot_id
best_meta = meta
if not best_id:
sys.exit("no snapshot matched")
print(best_id)
print(
"\t".join(
[
best_id,
str(best_meta.get("state", "")).strip(),
str(best_meta.get("name", "")).strip(),
]
)
)
PY
}
@@ -338,12 +367,31 @@ restore_snapshot() {
local snapshot_id="$1"
say "Restore snapshot $SNAPSHOT_HINT ($snapshot_id)"
prlctl snapshot-switch "$VM_NAME" --id "$snapshot_id" >/dev/null
if [[ "$SNAPSHOT_STATE" == "poweroff" ]]; then
wait_for_vm_status "stopped" || die "restored poweroff snapshot did not reach stopped state in $VM_NAME"
say "Start restored poweroff snapshot $SNAPSHOT_NAME"
prlctl start "$VM_NAME" >/dev/null
fi
}
verify_windows_user_ready() {
guest_exec cmd.exe /d /s /c "echo ready"
}
wait_for_vm_status() {
local expected="$1"
local deadline status
deadline=$((SECONDS + TIMEOUT_SNAPSHOT_S))
while (( SECONDS < deadline )); do
status="$(prlctl status "$VM_NAME" 2>/dev/null || true)"
if [[ "$status" == *" $expected" ]]; then
return 0
fi
sleep 1
done
return 1
}
wait_for_guest_ready() {
local deadline
deadline=$((SECONDS + TIMEOUT_SNAPSHOT_S))
@@ -830,13 +878,16 @@ run_upgrade_lane() {
UPGRADE_AGENT_STATUS="pass"
}
SNAPSHOT_ID="$(resolve_snapshot_id)"
IFS=$'\t' read -r SNAPSHOT_ID SNAPSHOT_STATE SNAPSHOT_NAME <<<"$(resolve_snapshot_info)"
[[ -n "$SNAPSHOT_ID" ]] || die "failed to resolve snapshot id"
[[ -n "$SNAPSHOT_NAME" ]] || SNAPSHOT_NAME="$SNAPSHOT_HINT"
LATEST_VERSION="$(resolve_latest_version)"
HOST_IP="$(resolve_host_ip)"
HOST_PORT="$(resolve_host_port)"
say "VM: $VM_NAME"
say "Snapshot hint: $SNAPSHOT_HINT"
say "Resolved snapshot: $SNAPSHOT_NAME [$SNAPSHOT_STATE]"
say "Latest npm version: $LATEST_VERSION"
say "Current head: $(git rev-parse --short HEAD)"
say "Run logs: $RUN_DIR"